@orbat-mapper/tactical-draw 0.2.0-alpha.14 → 0.2.0-alpha.16

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
@@ -2031,18 +2034,23 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2031
2034
  */
2032
2035
  const DIRTY_EPSILON$1 = 1e-9;
2033
2036
  const HANDLE_HIT_TOLERANCE_PX$1 = 12;
2037
+ /** Screen-space clearance between a label's anchor and its visible drag grip. */
2038
+ const LABEL_HANDLE_OFFSET_PX = 14;
2034
2039
  /**
2035
2040
  * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
2036
2041
  * lands within this many pixels of the down pixel is treated as a plain
2037
2042
  * click, not a gesture: it is not committed (geometry restored, no
2038
- * `EditChangeEvent`, `dirty` untouched) but the click-away listener is
2039
- * suppressed so the click doesn't close the edit. Corner/rotate drags commit
2040
- * even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
2043
+ * `EditChangeEvent`, `dirty` untouched). The click-away's *empty-map* close is
2044
+ * suppressed so the click doesn't end the edit, but a pick on another
2045
+ * graphics-layer measure inside the box still closes-then-selects it.
2046
+ * Corner/rotate drags commit even at zero movement (matches the pre-ADR-0017
2047
+ * translate/rotate seams).
2041
2048
  */
2042
2049
  const CLICK_SLOP_PX$1 = 3;
2043
2050
  const ALLOWED_EDIT_MODES = new Set([
2044
2051
  "reshape",
2045
2052
  "transform",
2053
+ "labeldrag",
2046
2054
  "delete"
2047
2055
  ]);
2048
2056
  /**
@@ -2072,6 +2080,10 @@ function runEdit(opts) {
2072
2080
  const workingPoints = editStartPoints.map((p) => [...p]);
2073
2081
  let workingOptions = { ...measure.options ?? {} };
2074
2082
  let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
2083
+ let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
2084
+ function snapshotAmplifierPlacements() {
2085
+ return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
2086
+ }
2075
2087
  let modes = requestedModes;
2076
2088
  let dirty = false;
2077
2089
  let nonGeometryDirty = false;
@@ -2083,6 +2095,15 @@ function runEdit(opts) {
2083
2095
  */
2084
2096
  let suppressClickAway = false;
2085
2097
  /**
2098
+ * Set when a zero-movement body drag (a plain click inside the transform
2099
+ * box) released this pointer cycle. Unlike {@link suppressClickAway} it only
2100
+ * suppresses the *empty-map* close in `handleClick` — a stashed pick on
2101
+ * another graphics-layer measure still closes-then-re-dispatches, so
2102
+ * measures sitting inside a large box stay selectable. Consumed by the next
2103
+ * `handleClick`.
2104
+ */
2105
+ let bodyClickPending = false;
2106
+ /**
2086
2107
  * Mutable style snapshot. Initialised from the edit-start input; replaced
2087
2108
  * when the façade routes an external style change to us via
2088
2109
  * `notifyExternalMeasure`. `EditSession.measure` stays the immutable
@@ -2162,6 +2183,8 @@ function runEdit(opts) {
2162
2183
  };
2163
2184
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2164
2185
  else delete working.textAmplifiers;
2186
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2187
+ else delete working.amplifierPlacements;
2165
2188
  let features;
2166
2189
  try {
2167
2190
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -2191,6 +2214,31 @@ function runEdit(opts) {
2191
2214
  trailingFixedSlots,
2192
2215
  closedRing
2193
2216
  }), handleIdPrefix, interactionStyle);
2217
+ if (modes.includes("labeldrag")) {
2218
+ const style = interactionStyle?.labelHandle;
2219
+ let index = 0;
2220
+ for (const feature of lastPreviewFeatures) {
2221
+ const key = feature.properties?.labelPlacementKey;
2222
+ if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2223
+ const pixel = driver.toPixel(feature.geometry.coordinates);
2224
+ if (!pixel) continue;
2225
+ features.push({
2226
+ type: "Feature",
2227
+ id: `${handleIdPrefix}:label-handle:${index}`,
2228
+ geometry: {
2229
+ type: "Point",
2230
+ coordinates: driver.fromPixel([pixel[0], pixel[1] - LABEL_HANDLE_OFFSET_PX])
2231
+ },
2232
+ properties: {
2233
+ handle: "label",
2234
+ index,
2235
+ labelPlacementKey: key,
2236
+ ...style ? { style: { ...style } } : {}
2237
+ }
2238
+ });
2239
+ index += 1;
2240
+ }
2241
+ }
2194
2242
  if (modes.includes("transform")) {
2195
2243
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
2196
2244
  if (liveBox) {
@@ -2215,6 +2263,8 @@ function runEdit(opts) {
2215
2263
  };
2216
2264
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2217
2265
  else delete working.textAmplifiers;
2266
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2267
+ else delete working.amplifierPlacements;
2218
2268
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2219
2269
  return {
2220
2270
  measure: cloneControlMeasure(committed),
@@ -2266,7 +2316,7 @@ function runEdit(opts) {
2266
2316
  * - Reject restores the working state to the pre-gesture snapshot and
2267
2317
  * recomputes `dirty`. Close/abort settle the session as normal.
2268
2318
  */
2269
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers) {
2319
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements) {
2270
2320
  if (ctx.settled) return;
2271
2321
  let rejectRequested = false;
2272
2322
  let closeRequested = false;
@@ -2299,6 +2349,7 @@ function runEdit(opts) {
2299
2349
  currentStyle = previousStyle;
2300
2350
  sizeAnchor = previousSizeAnchor;
2301
2351
  workingTextAmplifiers = previousTextAmplifiers;
2352
+ workingAmplifierPlacements = previousAmplifierPlacements;
2302
2353
  nonGeometryDirty = previousNonGeometryDirty;
2303
2354
  renderPreview();
2304
2355
  renderHandlesLayer();
@@ -2317,20 +2368,23 @@ function runEdit(opts) {
2317
2368
  * queued during the drag are applied after the listener emission (so the
2318
2369
  * post-gesture render reflects the requested mode set).
2319
2370
  */
2320
- function commitGesture(nextPoints, preGestureOptions) {
2371
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2321
2372
  if (ctx.settled) return;
2322
2373
  const previousPoints = workingPoints.map((p) => [...p]);
2323
2374
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
2324
2375
  const previousStyle = currentStyle;
2325
2376
  const previousNonGeometryDirty = nonGeometryDirty;
2326
2377
  const previousTextAmplifiers = workingTextAmplifiers;
2378
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2327
2379
  const previousRendered = rebuildRenderedWith(previousOptions);
2328
2380
  replaceWorkingPoints(nextPoints);
2381
+ if (nextAmplifierPlacements !== void 0) workingAmplifierPlacements = structuredClone(nextAmplifierPlacements);
2329
2382
  let currentRendered;
2330
2383
  try {
2331
2384
  currentRendered = rebuildRendered();
2332
2385
  } catch (err) {
2333
2386
  replaceWorkingPoints(previousPoints);
2387
+ workingAmplifierPlacements = previousAmplifierPlacements;
2334
2388
  if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
2335
2389
  dragInFlight = false;
2336
2390
  drainPendingModes();
@@ -2341,7 +2395,7 @@ function runEdit(opts) {
2341
2395
  recomputeDirty();
2342
2396
  dragInFlight = false;
2343
2397
  drainPendingModes();
2344
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2398
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2345
2399
  }
2346
2400
  function setOptions(partial) {
2347
2401
  if (ctx.settled) return;
@@ -2350,6 +2404,7 @@ function runEdit(opts) {
2350
2404
  const previousStyle = currentStyle;
2351
2405
  const previousNonGeometryDirty = nonGeometryDirty;
2352
2406
  const previousTextAmplifiers = workingTextAmplifiers;
2407
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2353
2408
  const previousRendered = rebuildRendered();
2354
2409
  replaceWorkingOptions({
2355
2410
  ...workingOptions ?? {},
@@ -2365,7 +2420,7 @@ function runEdit(opts) {
2365
2420
  renderPreview();
2366
2421
  nonGeometryDirty = true;
2367
2422
  dirty = true;
2368
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2423
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2369
2424
  }
2370
2425
  function setStyle(partial) {
2371
2426
  if (ctx.settled) return;
@@ -2374,6 +2429,7 @@ function runEdit(opts) {
2374
2429
  const previousStyle = currentStyle;
2375
2430
  const previousNonGeometryDirty = nonGeometryDirty;
2376
2431
  const previousTextAmplifiers = workingTextAmplifiers;
2432
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2377
2433
  const previousRendered = rebuildRendered();
2378
2434
  currentStyle = {
2379
2435
  ...currentStyle ?? {},
@@ -2389,7 +2445,7 @@ function runEdit(opts) {
2389
2445
  renderPreview();
2390
2446
  nonGeometryDirty = true;
2391
2447
  dirty = true;
2392
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2448
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2393
2449
  }
2394
2450
  /**
2395
2451
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2413,6 +2469,7 @@ function runEdit(opts) {
2413
2469
  const previousStyle = currentStyle;
2414
2470
  const previousNonGeometryDirty = nonGeometryDirty;
2415
2471
  const previousTextAmplifiers = workingTextAmplifiers;
2472
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2416
2473
  const previousRendered = rebuildRendered();
2417
2474
  const merged = { ...workingTextAmplifiers ?? {} };
2418
2475
  for (const [key, value] of Object.entries(partial)) {
@@ -2433,7 +2490,26 @@ function runEdit(opts) {
2433
2490
  renderPreview();
2434
2491
  nonGeometryDirty = true;
2435
2492
  dirty = true;
2436
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2493
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2494
+ }
2495
+ function setAmplifierPlacements(partial) {
2496
+ if (ctx.settled) return;
2497
+ const previousPoints = workingPoints.map((p) => [...p]);
2498
+ const previousOptions = { ...workingOptions ?? {} };
2499
+ const previousStyle = currentStyle;
2500
+ const previousNonGeometryDirty = nonGeometryDirty;
2501
+ const previousTextAmplifiers = workingTextAmplifiers;
2502
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2503
+ const previousRendered = rebuildRendered();
2504
+ const next = { ...workingAmplifierPlacements ?? {} };
2505
+ for (const [field, position] of Object.entries(partial)) if (position === void 0) delete next[field];
2506
+ else next[field] = [...position];
2507
+ workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2508
+ const currentRendered = rebuildRendered();
2509
+ renderPreview();
2510
+ nonGeometryDirty = true;
2511
+ dirty = true;
2512
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2437
2513
  }
2438
2514
  /**
2439
2515
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2450,12 +2526,13 @@ function runEdit(opts) {
2450
2526
  const previousNonGeometryDirty = nonGeometryDirty;
2451
2527
  const previousSizeAnchor = sizeAnchor;
2452
2528
  const previousTextAmplifiers = workingTextAmplifiers;
2529
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2453
2530
  const previousRendered = rebuildRendered();
2454
2531
  sizeAnchor = next;
2455
2532
  const currentRendered = rebuildRendered();
2456
2533
  nonGeometryDirty = true;
2457
2534
  dirty = true;
2458
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers);
2535
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2459
2536
  }
2460
2537
  /** Apply a translate gesture by shifting every working point by `delta`. */
2461
2538
  function commitTranslate(delta) {
@@ -2516,6 +2593,9 @@ function runEdit(opts) {
2516
2593
  get textAmplifiers() {
2517
2594
  return workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0;
2518
2595
  },
2596
+ get amplifierPlacements() {
2597
+ return snapshotAmplifierPlacements();
2598
+ },
2519
2599
  get dirty() {
2520
2600
  return dirty;
2521
2601
  },
@@ -2550,6 +2630,9 @@ function runEdit(opts) {
2550
2630
  setTextAmplifiers(partial) {
2551
2631
  setTextAmplifiers(partial);
2552
2632
  },
2633
+ setAmplifierPlacements(partial) {
2634
+ setAmplifierPlacements(partial);
2635
+ },
2553
2636
  setSizeAnchor(next) {
2554
2637
  setSizeAnchor(next);
2555
2638
  },
@@ -2587,6 +2670,31 @@ function runEdit(opts) {
2587
2670
  }
2588
2671
  };
2589
2672
  let activeDrag = null;
2673
+ let activeAmplifierDrag = null;
2674
+ function pickLabel(pixel, tolerance) {
2675
+ for (const feature of lastPreviewFeatures) {
2676
+ const key = feature.properties?.labelPlacementKey;
2677
+ if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2678
+ const projected = driver.toPixel(feature.geometry.coordinates);
2679
+ if (!projected) continue;
2680
+ for (const handle of [false, true]) {
2681
+ const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
2682
+ if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
2683
+ key,
2684
+ handle
2685
+ };
2686
+ }
2687
+ }
2688
+ return null;
2689
+ }
2690
+ /**
2691
+ * The label anchor a pointer pixel maps to: 1:1 for a grab on the label
2692
+ * itself, offset back down by the grip clearance when the drag rides the
2693
+ * visible handle, so the label lands where the anchor (not the grip) points.
2694
+ */
2695
+ function labelAnchorFromPointer(pixel, handle) {
2696
+ return driver.fromPixel([pixel[0], pixel[1] + (handle ? LABEL_HANDLE_OFFSET_PX : 0)]);
2697
+ }
2590
2698
  /**
2591
2699
  * A live transform-box gesture (corner scale, box rotate, or body
2592
2700
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
@@ -2596,6 +2704,24 @@ function runEdit(opts) {
2596
2704
  * `boxAngle`.
2597
2705
  */
2598
2706
  let activeBoxDrag = null;
2707
+ function transformAmplifierPlacements(placements, from, to) {
2708
+ if (!placements) return void 0;
2709
+ const scaleX = to.halfWidth / from.halfWidth;
2710
+ const scaleY = to.halfHeight / from.halfHeight;
2711
+ const transformed = {};
2712
+ for (const [field, position] of Object.entries(placements)) {
2713
+ if (position === void 0) continue;
2714
+ const pixel = driver.toPixel(position);
2715
+ if (!pixel) {
2716
+ transformed[field] = [...position];
2717
+ continue;
2718
+ }
2719
+ const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
2720
+ const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
2721
+ transformed[field] = driver.fromPixel([to.center[0] + outX, to.center[1] + outY]);
2722
+ }
2723
+ return transformed;
2724
+ }
2599
2725
  /**
2600
2726
  * Whether the vertex at `index` may be deleted (via Alt+click or in the
2601
2727
  * exclusive `delete` mode). Deletion is
@@ -2635,6 +2761,18 @@ function runEdit(opts) {
2635
2761
  const onDriverDown = (pixel, info) => {
2636
2762
  if (ctx.settled || dragInFlight) return;
2637
2763
  const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2764
+ const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
2765
+ if (labelHit) {
2766
+ activeAmplifierDrag = {
2767
+ field: labelHit.key,
2768
+ handle: labelHit.handle,
2769
+ previous: snapshotAmplifierPlacements(),
2770
+ downPixel: pixel
2771
+ };
2772
+ dragInFlight = true;
2773
+ driver.setDragPanEnabled(false);
2774
+ return;
2775
+ }
2638
2776
  const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2639
2777
  includeMidpoints: midpointHandlesEnabled,
2640
2778
  trailingFixedSlots,
@@ -2694,6 +2832,7 @@ function runEdit(opts) {
2694
2832
  cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
2695
2833
  }),
2696
2834
  preDragPoints,
2835
+ preDragAmplifierPlacements: snapshotAmplifierPlacements(),
2697
2836
  preDragBox: box,
2698
2837
  downPixel: pixel,
2699
2838
  lastSnapshot: {
@@ -2720,13 +2859,23 @@ function runEdit(opts) {
2720
2859
  const snapshot = activeBoxDrag.fsm.move(pixel);
2721
2860
  activeBoxDrag.lastSnapshot = snapshot;
2722
2861
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2862
+ workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box);
2723
2863
  renderPreview();
2724
2864
  renderHandlesLayer();
2725
2865
  recomputeDirty();
2726
2866
  }
2727
2867
  const onDriverMove = (pixel) => {
2728
2868
  if (ctx.settled) return;
2729
- if (activeDrag) paintDragSnapshot(pixel);
2869
+ if (activeAmplifierDrag) {
2870
+ const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.handle);
2871
+ if (!position) return;
2872
+ workingAmplifierPlacements = {
2873
+ ...workingAmplifierPlacements ?? {},
2874
+ [activeAmplifierDrag.field]: position
2875
+ };
2876
+ renderPreview();
2877
+ renderHandlesLayer();
2878
+ } else if (activeDrag) paintDragSnapshot(pixel);
2730
2879
  else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2731
2880
  };
2732
2881
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
@@ -2736,6 +2885,19 @@ function runEdit(opts) {
2736
2885
  drainPendingModes();
2737
2886
  }
2738
2887
  const onDriverUp = (pixel) => {
2888
+ if (activeAmplifierDrag) {
2889
+ const { field, handle, previous, downPixel } = activeAmplifierDrag;
2890
+ activeAmplifierDrag = null;
2891
+ const position = labelAnchorFromPointer(pixel, handle);
2892
+ workingAmplifierPlacements = previous;
2893
+ endDragBookkeeping();
2894
+ if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
2895
+ else {
2896
+ renderPreview();
2897
+ renderHandlesLayer();
2898
+ }
2899
+ return;
2900
+ }
2739
2901
  if (activeDrag) {
2740
2902
  if (ctx.settled) {
2741
2903
  activeDrag = null;
@@ -2758,23 +2920,25 @@ function runEdit(opts) {
2758
2920
  driver.setDragPanEnabled(true);
2759
2921
  return;
2760
2922
  }
2761
- const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2923
+ const { fsm, preDragPoints, preDragAmplifierPlacements, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2762
2924
  activeBoxDrag = null;
2763
2925
  driver.setDragPanEnabled(true);
2764
2926
  const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
2765
2927
  if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
2766
2928
  replaceWorkingPoints(preDragPoints);
2929
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2767
2930
  renderPreview();
2768
2931
  renderHandlesLayer();
2769
2932
  recomputeDirty();
2770
2933
  endDragBookkeeping();
2771
- suppressClickAway = true;
2934
+ bodyClickPending = true;
2772
2935
  return;
2773
2936
  }
2774
2937
  const finalSnapshot = fsm.move(pixel);
2775
2938
  replaceWorkingPoints(preDragPoints);
2939
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2776
2940
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2777
- commitGesture(finalSnapshot.points, preDragOptions);
2941
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box));
2778
2942
  return;
2779
2943
  }
2780
2944
  };
@@ -2794,21 +2958,25 @@ function runEdit(opts) {
2794
2958
  const localPreview = pickPreview;
2795
2959
  const localHandles = pickHandles;
2796
2960
  const localGraphics = pickGraphics;
2961
+ const wasBodyClick = bodyClickPending;
2797
2962
  pickPreview = void 0;
2798
2963
  pickHandles = void 0;
2799
2964
  pickGraphics = void 0;
2965
+ bodyClickPending = false;
2800
2966
  if (ctx.settled) return;
2801
2967
  if (!closeOnClickAway) return;
2802
2968
  if (suppressClickAway) {
2803
2969
  suppressClickAway = false;
2804
2970
  return;
2805
2971
  }
2806
- if (localPreview || localHandles) return;
2972
+ if (localHandles) return;
2807
2973
  if (localGraphics && localGraphics.id !== measure.id) {
2808
2974
  ctx.commit(rebuildRendered);
2809
2975
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
2810
2976
  return;
2811
2977
  }
2978
+ if (localPreview) return;
2979
+ if (wasBodyClick) return;
2812
2980
  ctx.commit(rebuildRendered);
2813
2981
  };
2814
2982
  const core = runInteraction({
@@ -2843,10 +3011,11 @@ function runEdit(opts) {
2843
3011
  unsubDown();
2844
3012
  unsubMove();
2845
3013
  unsubUp();
2846
- if (activeDrag !== null || activeBoxDrag !== null) {
3014
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
2847
3015
  driver.setDragPanEnabled(true);
2848
3016
  activeDrag = null;
2849
3017
  activeBoxDrag = null;
3018
+ activeAmplifierDrag = null;
2850
3019
  dragInFlight = false;
2851
3020
  }
2852
3021
  driver.dispose();
@@ -3015,6 +3184,8 @@ function runGroupTransform(opts) {
3015
3184
  let dragInFlight = false;
3016
3185
  /** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
3017
3186
  let suppressClickAway = false;
3187
+ /** Same body-click role as `edit-controller.ts`'s flag of the same name. */
3188
+ let bodyClickPending = false;
3018
3189
  const changeListeners = /* @__PURE__ */ new Set();
3019
3190
  const commitEmitter = createCommitEmitter("TransformSession.onCommit listener errors");
3020
3191
  const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
@@ -3448,7 +3619,7 @@ function runGroupTransform(opts) {
3448
3619
  renderHandlesLayer();
3449
3620
  recomputeDirty();
3450
3621
  dragInFlight = false;
3451
- suppressClickAway = true;
3622
+ bodyClickPending = true;
3452
3623
  return;
3453
3624
  }
3454
3625
  const finalSnapshot = fsm.move(pixel);
@@ -3472,22 +3643,26 @@ function runGroupTransform(opts) {
3472
3643
  const localPreview = pickPreview;
3473
3644
  const localHandles = pickHandles;
3474
3645
  const localGraphics = pickGraphics;
3646
+ const wasBodyClick = bodyClickPending;
3475
3647
  pickPreview = void 0;
3476
3648
  pickHandles = void 0;
3477
3649
  pickGraphics = void 0;
3650
+ bodyClickPending = false;
3478
3651
  if (ctx.settled) return;
3479
3652
  if (!closeOnClickAway) return;
3480
3653
  if (suppressClickAway) {
3481
3654
  suppressClickAway = false;
3482
3655
  return;
3483
3656
  }
3484
- if (localPreview || localHandles) return;
3657
+ if (localHandles) return;
3485
3658
  const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
3486
3659
  if (localGraphics && !isMember) {
3487
3660
  commitClose();
3488
3661
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
3489
3662
  return;
3490
3663
  }
3664
+ if (localPreview) return;
3665
+ if (wasBodyClick) return;
3491
3666
  commitClose();
3492
3667
  };
3493
3668
  const core = runInteraction({
@@ -4303,6 +4478,12 @@ const BUILT_IN_INTERACTION_STYLE = {
4303
4478
  strokeColor: "#6b7280",
4304
4479
  strokeWidth: 1.5
4305
4480
  },
4481
+ labelHandle: {
4482
+ pointRadius: 6,
4483
+ fillColor: "#7c3aed",
4484
+ strokeColor: "#ffffff",
4485
+ strokeWidth: 2
4486
+ },
4306
4487
  scaleHandle: {
4307
4488
  pointRadius: 5,
4308
4489
  fillColor: "#ffffff",
@@ -4319,6 +4500,7 @@ const BUILT_IN_INTERACTION_STYLE = {
4319
4500
  const HANDLE_SLOTS = [
4320
4501
  "vertexHandle",
4321
4502
  "midpointHandle",
4503
+ "labelHandle",
4322
4504
  "scaleHandle",
4323
4505
  "rotateHandle"
4324
4506
  ];
@@ -4382,4 +4564,4 @@ function nearestWorldCopyPosition(position, refLng) {
4382
4564
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
4383
4565
  }
4384
4566
  //#endregion
4385
- 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 };
4567
+ 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.14",
3
+ "version": "0.2.0-alpha.16",
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.12"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",