@orbat-mapper/tactical-draw 0.2.0-alpha.15 → 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,6 +2034,8 @@ 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
@@ -2045,6 +2050,7 @@ const CLICK_SLOP_PX$1 = 3;
2045
2050
  const ALLOWED_EDIT_MODES = new Set([
2046
2051
  "reshape",
2047
2052
  "transform",
2053
+ "labeldrag",
2048
2054
  "delete"
2049
2055
  ]);
2050
2056
  /**
@@ -2074,6 +2080,10 @@ function runEdit(opts) {
2074
2080
  const workingPoints = editStartPoints.map((p) => [...p]);
2075
2081
  let workingOptions = { ...measure.options ?? {} };
2076
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
+ }
2077
2087
  let modes = requestedModes;
2078
2088
  let dirty = false;
2079
2089
  let nonGeometryDirty = false;
@@ -2173,6 +2183,8 @@ function runEdit(opts) {
2173
2183
  };
2174
2184
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2175
2185
  else delete working.textAmplifiers;
2186
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2187
+ else delete working.amplifierPlacements;
2176
2188
  let features;
2177
2189
  try {
2178
2190
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -2202,6 +2214,31 @@ function runEdit(opts) {
2202
2214
  trailingFixedSlots,
2203
2215
  closedRing
2204
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
+ }
2205
2242
  if (modes.includes("transform")) {
2206
2243
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
2207
2244
  if (liveBox) {
@@ -2226,6 +2263,8 @@ function runEdit(opts) {
2226
2263
  };
2227
2264
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2228
2265
  else delete working.textAmplifiers;
2266
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2267
+ else delete working.amplifierPlacements;
2229
2268
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2230
2269
  return {
2231
2270
  measure: cloneControlMeasure(committed),
@@ -2277,7 +2316,7 @@ function runEdit(opts) {
2277
2316
  * - Reject restores the working state to the pre-gesture snapshot and
2278
2317
  * recomputes `dirty`. Close/abort settle the session as normal.
2279
2318
  */
2280
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers) {
2319
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements) {
2281
2320
  if (ctx.settled) return;
2282
2321
  let rejectRequested = false;
2283
2322
  let closeRequested = false;
@@ -2310,6 +2349,7 @@ function runEdit(opts) {
2310
2349
  currentStyle = previousStyle;
2311
2350
  sizeAnchor = previousSizeAnchor;
2312
2351
  workingTextAmplifiers = previousTextAmplifiers;
2352
+ workingAmplifierPlacements = previousAmplifierPlacements;
2313
2353
  nonGeometryDirty = previousNonGeometryDirty;
2314
2354
  renderPreview();
2315
2355
  renderHandlesLayer();
@@ -2328,20 +2368,23 @@ function runEdit(opts) {
2328
2368
  * queued during the drag are applied after the listener emission (so the
2329
2369
  * post-gesture render reflects the requested mode set).
2330
2370
  */
2331
- function commitGesture(nextPoints, preGestureOptions) {
2371
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2332
2372
  if (ctx.settled) return;
2333
2373
  const previousPoints = workingPoints.map((p) => [...p]);
2334
2374
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
2335
2375
  const previousStyle = currentStyle;
2336
2376
  const previousNonGeometryDirty = nonGeometryDirty;
2337
2377
  const previousTextAmplifiers = workingTextAmplifiers;
2378
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2338
2379
  const previousRendered = rebuildRenderedWith(previousOptions);
2339
2380
  replaceWorkingPoints(nextPoints);
2381
+ if (nextAmplifierPlacements !== void 0) workingAmplifierPlacements = structuredClone(nextAmplifierPlacements);
2340
2382
  let currentRendered;
2341
2383
  try {
2342
2384
  currentRendered = rebuildRendered();
2343
2385
  } catch (err) {
2344
2386
  replaceWorkingPoints(previousPoints);
2387
+ workingAmplifierPlacements = previousAmplifierPlacements;
2345
2388
  if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
2346
2389
  dragInFlight = false;
2347
2390
  drainPendingModes();
@@ -2352,7 +2395,7 @@ function runEdit(opts) {
2352
2395
  recomputeDirty();
2353
2396
  dragInFlight = false;
2354
2397
  drainPendingModes();
2355
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2398
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2356
2399
  }
2357
2400
  function setOptions(partial) {
2358
2401
  if (ctx.settled) return;
@@ -2361,6 +2404,7 @@ function runEdit(opts) {
2361
2404
  const previousStyle = currentStyle;
2362
2405
  const previousNonGeometryDirty = nonGeometryDirty;
2363
2406
  const previousTextAmplifiers = workingTextAmplifiers;
2407
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2364
2408
  const previousRendered = rebuildRendered();
2365
2409
  replaceWorkingOptions({
2366
2410
  ...workingOptions ?? {},
@@ -2376,7 +2420,7 @@ function runEdit(opts) {
2376
2420
  renderPreview();
2377
2421
  nonGeometryDirty = true;
2378
2422
  dirty = true;
2379
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2423
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2380
2424
  }
2381
2425
  function setStyle(partial) {
2382
2426
  if (ctx.settled) return;
@@ -2385,6 +2429,7 @@ function runEdit(opts) {
2385
2429
  const previousStyle = currentStyle;
2386
2430
  const previousNonGeometryDirty = nonGeometryDirty;
2387
2431
  const previousTextAmplifiers = workingTextAmplifiers;
2432
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2388
2433
  const previousRendered = rebuildRendered();
2389
2434
  currentStyle = {
2390
2435
  ...currentStyle ?? {},
@@ -2400,7 +2445,7 @@ function runEdit(opts) {
2400
2445
  renderPreview();
2401
2446
  nonGeometryDirty = true;
2402
2447
  dirty = true;
2403
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2448
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2404
2449
  }
2405
2450
  /**
2406
2451
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2424,6 +2469,7 @@ function runEdit(opts) {
2424
2469
  const previousStyle = currentStyle;
2425
2470
  const previousNonGeometryDirty = nonGeometryDirty;
2426
2471
  const previousTextAmplifiers = workingTextAmplifiers;
2472
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2427
2473
  const previousRendered = rebuildRendered();
2428
2474
  const merged = { ...workingTextAmplifiers ?? {} };
2429
2475
  for (const [key, value] of Object.entries(partial)) {
@@ -2444,7 +2490,26 @@ function runEdit(opts) {
2444
2490
  renderPreview();
2445
2491
  nonGeometryDirty = true;
2446
2492
  dirty = true;
2447
- 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);
2448
2513
  }
2449
2514
  /**
2450
2515
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2461,12 +2526,13 @@ function runEdit(opts) {
2461
2526
  const previousNonGeometryDirty = nonGeometryDirty;
2462
2527
  const previousSizeAnchor = sizeAnchor;
2463
2528
  const previousTextAmplifiers = workingTextAmplifiers;
2529
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2464
2530
  const previousRendered = rebuildRendered();
2465
2531
  sizeAnchor = next;
2466
2532
  const currentRendered = rebuildRendered();
2467
2533
  nonGeometryDirty = true;
2468
2534
  dirty = true;
2469
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers);
2535
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2470
2536
  }
2471
2537
  /** Apply a translate gesture by shifting every working point by `delta`. */
2472
2538
  function commitTranslate(delta) {
@@ -2527,6 +2593,9 @@ function runEdit(opts) {
2527
2593
  get textAmplifiers() {
2528
2594
  return workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0;
2529
2595
  },
2596
+ get amplifierPlacements() {
2597
+ return snapshotAmplifierPlacements();
2598
+ },
2530
2599
  get dirty() {
2531
2600
  return dirty;
2532
2601
  },
@@ -2561,6 +2630,9 @@ function runEdit(opts) {
2561
2630
  setTextAmplifiers(partial) {
2562
2631
  setTextAmplifiers(partial);
2563
2632
  },
2633
+ setAmplifierPlacements(partial) {
2634
+ setAmplifierPlacements(partial);
2635
+ },
2564
2636
  setSizeAnchor(next) {
2565
2637
  setSizeAnchor(next);
2566
2638
  },
@@ -2598,6 +2670,31 @@ function runEdit(opts) {
2598
2670
  }
2599
2671
  };
2600
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
+ }
2601
2698
  /**
2602
2699
  * A live transform-box gesture (corner scale, box rotate, or body
2603
2700
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
@@ -2607,6 +2704,24 @@ function runEdit(opts) {
2607
2704
  * `boxAngle`.
2608
2705
  */
2609
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
+ }
2610
2725
  /**
2611
2726
  * Whether the vertex at `index` may be deleted (via Alt+click or in the
2612
2727
  * exclusive `delete` mode). Deletion is
@@ -2646,6 +2761,18 @@ function runEdit(opts) {
2646
2761
  const onDriverDown = (pixel, info) => {
2647
2762
  if (ctx.settled || dragInFlight) return;
2648
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
+ }
2649
2776
  const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2650
2777
  includeMidpoints: midpointHandlesEnabled,
2651
2778
  trailingFixedSlots,
@@ -2705,6 +2832,7 @@ function runEdit(opts) {
2705
2832
  cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
2706
2833
  }),
2707
2834
  preDragPoints,
2835
+ preDragAmplifierPlacements: snapshotAmplifierPlacements(),
2708
2836
  preDragBox: box,
2709
2837
  downPixel: pixel,
2710
2838
  lastSnapshot: {
@@ -2731,13 +2859,23 @@ function runEdit(opts) {
2731
2859
  const snapshot = activeBoxDrag.fsm.move(pixel);
2732
2860
  activeBoxDrag.lastSnapshot = snapshot;
2733
2861
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2862
+ workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box);
2734
2863
  renderPreview();
2735
2864
  renderHandlesLayer();
2736
2865
  recomputeDirty();
2737
2866
  }
2738
2867
  const onDriverMove = (pixel) => {
2739
2868
  if (ctx.settled) return;
2740
- 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);
2741
2879
  else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2742
2880
  };
2743
2881
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
@@ -2747,6 +2885,19 @@ function runEdit(opts) {
2747
2885
  drainPendingModes();
2748
2886
  }
2749
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
+ }
2750
2901
  if (activeDrag) {
2751
2902
  if (ctx.settled) {
2752
2903
  activeDrag = null;
@@ -2769,12 +2920,13 @@ function runEdit(opts) {
2769
2920
  driver.setDragPanEnabled(true);
2770
2921
  return;
2771
2922
  }
2772
- const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2923
+ const { fsm, preDragPoints, preDragAmplifierPlacements, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2773
2924
  activeBoxDrag = null;
2774
2925
  driver.setDragPanEnabled(true);
2775
2926
  const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
2776
2927
  if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
2777
2928
  replaceWorkingPoints(preDragPoints);
2929
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2778
2930
  renderPreview();
2779
2931
  renderHandlesLayer();
2780
2932
  recomputeDirty();
@@ -2784,8 +2936,9 @@ function runEdit(opts) {
2784
2936
  }
2785
2937
  const finalSnapshot = fsm.move(pixel);
2786
2938
  replaceWorkingPoints(preDragPoints);
2939
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2787
2940
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2788
- commitGesture(finalSnapshot.points, preDragOptions);
2941
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box));
2789
2942
  return;
2790
2943
  }
2791
2944
  };
@@ -2858,10 +3011,11 @@ function runEdit(opts) {
2858
3011
  unsubDown();
2859
3012
  unsubMove();
2860
3013
  unsubUp();
2861
- if (activeDrag !== null || activeBoxDrag !== null) {
3014
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
2862
3015
  driver.setDragPanEnabled(true);
2863
3016
  activeDrag = null;
2864
3017
  activeBoxDrag = null;
3018
+ activeAmplifierDrag = null;
2865
3019
  dragInFlight = false;
2866
3020
  }
2867
3021
  driver.dispose();
@@ -4324,6 +4478,12 @@ const BUILT_IN_INTERACTION_STYLE = {
4324
4478
  strokeColor: "#6b7280",
4325
4479
  strokeWidth: 1.5
4326
4480
  },
4481
+ labelHandle: {
4482
+ pointRadius: 6,
4483
+ fillColor: "#7c3aed",
4484
+ strokeColor: "#ffffff",
4485
+ strokeWidth: 2
4486
+ },
4327
4487
  scaleHandle: {
4328
4488
  pointRadius: 5,
4329
4489
  fillColor: "#ffffff",
@@ -4340,6 +4500,7 @@ const BUILT_IN_INTERACTION_STYLE = {
4340
4500
  const HANDLE_SLOTS = [
4341
4501
  "vertexHandle",
4342
4502
  "midpointHandle",
4503
+ "labelHandle",
4343
4504
  "scaleHandle",
4344
4505
  "rotateHandle"
4345
4506
  ];
@@ -4403,4 +4564,4 @@ function nearestWorldCopyPosition(position, refLng) {
4403
4564
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
4404
4565
  }
4405
4566
  //#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 };
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.15",
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",