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

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 { AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
1
+ import { AmplifierPlacement, 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
@@ -238,6 +238,12 @@ interface MapAdapter {
238
238
  * Get current map zoom level
239
239
  */
240
240
  getZoom(): number | undefined;
241
+ /**
242
+ * Get current view bearing, radians — the clockwise-from-north compass
243
+ * direction that screen-up points to (MapLibre-bearing semantics). `0` for
244
+ * engines/views that don't rotate.
245
+ */
246
+ getBearing(): number;
241
247
  /**
242
248
  * Listen for view changes (zoom/resolution)
243
249
  */
@@ -328,6 +334,11 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
328
334
  abstract setDoubleClickZoomEnabled(enabled: boolean): void;
329
335
  abstract getResolution(): number | undefined;
330
336
  abstract getZoom(): number | undefined;
337
+ /**
338
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
339
+ * (MapLibre, OpenLayers) override this.
340
+ */
341
+ getBearing(): number;
331
342
  }
332
343
  //#endregion
333
344
  //#region src/pick-utils.d.ts
@@ -620,8 +631,15 @@ interface EditSession {
620
631
  * silent no-op after the session has settled (like [[setOptions]]).
621
632
  */
622
633
  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;
634
+ /**
635
+ * Merge field-keyed label overrides (position and/or rotation). The merge is
636
+ * **field-wise per key**: a bare `Position` value means `{ position }`;
637
+ * fields absent from the incoming value (e.g. `rotation` when only
638
+ * `position` is supplied) are preserved from the existing entry; an
639
+ * explicit `undefined` value deletes the key entirely — a full reset to the
640
+ * doctrinal placement (both position and rotation).
641
+ */
642
+ setAmplifierPlacements(partial: Record<string, AmplifierPlacement | undefined>): void;
625
643
  /**
626
644
  * Subscribe to working-copy changes. The listener receives an
627
645
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
1
+ import { canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveAmplifierPlacement, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
2
2
  //#region src/base-map-adapter.ts
3
3
  /**
4
4
  * Abstract base class shared by all `MapAdapter` implementations.
@@ -173,6 +173,13 @@ var BaseMapAdapter = class {
173
173
  };
174
174
  }
175
175
  destroyEngineResources() {}
176
+ /**
177
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
178
+ * (MapLibre, OpenLayers) override this.
179
+ */
180
+ getBearing() {
181
+ return 0;
182
+ }
176
183
  };
177
184
  function pickHitTolerance(originalEvent) {
178
185
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
@@ -1688,13 +1695,15 @@ function boxCorners(box) {
1688
1695
  });
1689
1696
  }
1690
1697
  /**
1691
- * Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
1692
- * midpoint, along the box's local "up" direction, so the grip stays glued to
1693
- * the box (rotates with it) rather than floating at a fixed screen offset.
1698
+ * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
1699
+ * above the top-edge midpoint, along the box's local "up" direction, so the
1700
+ * grip stays glued to the box (rotates with it) rather than floating at a
1701
+ * fixed screen offset. The label transform box passes its own tighter
1702
+ * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
1694
1703
  */
1695
- function rotateGripPixel(box) {
1704
+ function rotateGripPixel(box, offset = 28) {
1696
1705
  const { center, halfHeight, angle } = box;
1697
- const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
1706
+ const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
1698
1707
  return [center[0] + rx, center[1] + ry];
1699
1708
  }
1700
1709
  /**
@@ -2026,28 +2035,111 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2026
2035
  //#endregion
2027
2036
  //#region src/tactical-draw/transform-amplifier-placements.ts
2028
2037
  /**
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.
2038
+ * Carry user-adjusted label anchors (and rotation overrides) through a
2039
+ * transform-box gesture. Anchors are transformed in pixel space alongside the
2040
+ * control points so the result remains visually rigid across map
2041
+ * projections.
2042
+ *
2043
+ * Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
2044
+ * clockwise on screen (see `transform-box.ts`). Adapters render a label's
2045
+ * on-screen clockwise angle as `π − rotation`, and a rotation delta is
2046
+ * invariant under the map→screen bearing, so an on-screen delta
2047
+ * `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
2048
+ * i.e. `rotation' = rotation − Δ`.
2032
2049
  */
2033
2050
  function transformAmplifierPlacements(placements, from, to, projection) {
2034
2051
  if (!placements) return void 0;
2035
2052
  const scaleX = to.halfWidth / from.halfWidth;
2036
2053
  const scaleY = to.halfHeight / from.halfHeight;
2054
+ const angleDelta = to.angle - from.angle;
2037
2055
  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];
2056
+ for (const [field, raw] of Object.entries(placements)) {
2057
+ const resolved = resolveAmplifierPlacement(raw);
2058
+ const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
2059
+ if (!resolved.position) {
2060
+ if (rotation !== void 0) transformed[field] = { rotation };
2043
2061
  continue;
2044
2062
  }
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]);
2063
+ const pixel = projection.toPixel(resolved.position);
2064
+ const position = pixel ? (() => {
2065
+ const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
2066
+ const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
2067
+ return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
2068
+ })() : [...resolved.position];
2069
+ transformed[field] = rotation !== void 0 ? {
2070
+ position,
2071
+ rotation
2072
+ } : position;
2048
2073
  }
2049
2074
  return transformed;
2050
2075
  }
2076
+ /** Estimated glyph width as a fraction of text height — a monospace-ish approximation, not a real text-metrics measurement. */
2077
+ const CHAR_WIDTH_FACTOR = .6;
2078
+ /** Estimated line height as a multiple of text height. */
2079
+ const LINE_HEIGHT_FACTOR = 1.2;
2080
+ /**
2081
+ * Normalize `angle` (radians) into `(-π, π]`, matching the range
2082
+ * `Math.atan2`/screen-angle math elsewhere in `tactical-draw` produces.
2083
+ */
2084
+ function normalizeAngle(angle) {
2085
+ const twoPi = Math.PI * 2;
2086
+ let a = angle % twoPi;
2087
+ if (a <= -Math.PI) a += twoPi;
2088
+ else if (a > Math.PI) a -= twoPi;
2089
+ return a;
2090
+ }
2091
+ /**
2092
+ * Fit a `TransformBox` around `input`'s label. Returns `null` when `text` is
2093
+ * empty/absent — there is nothing to box.
2094
+ *
2095
+ * - **Angle**: on-screen clockwise, derived from the stored `rotation` by the
2096
+ * same `π − rotation` formula adapters use to render the label (see
2097
+ * `ml-adapter.ts`'s `buildSymbolLayer`), further offset by `−bearing` since
2098
+ * labels render map-aligned (`text-rotation-alignment: "map"` / OL
2099
+ * `rotateWithView: true`) and rotate with the view, so the box tilts
2100
+ * exactly as the rendered text does.
2101
+ * - **Size**: `text` is split on `"\n"`; the longest line's character count
2102
+ * times `CHAR_WIDTH_FACTOR × h` gives the width, the line count times
2103
+ * `LINE_HEIGHT_FACTOR × h` gives the height, where `h` is the resolved
2104
+ * text-height-in-pixels, mirroring how the adapters render each anchor
2105
+ * kind (ADR-0028): screen-anchored `textSizePixels` is used as-is,
2106
+ * unclamped (only a `1px` sanity floor to keep the box non-degenerate);
2107
+ * ground-anchored `textSizeMeters / resolution` is clamped to the default
2108
+ * `[DEFAULT_TEXT_SIZE_MIN_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS]` band via
2109
+ * `resolveGroundTextSizePixels` (`feature-style.ts`); absent both,
2110
+ * `DEFAULT_LABEL_TEXT_SIZE_PX`. `LABEL_BOX_PADDING_PX` is added on every
2111
+ * side. Hosts can override the clamp band per layer style
2112
+ * (`TextStyleOptions.textSizeMinPixels`/`textSizeMaxPixels`), which this
2113
+ * estimate can't see — the default band keeps the box honest at extreme
2114
+ * zooms even when a host has customized it.
2115
+ * - **Center**: `anchor` for centered text (`textAnchor` absent); offset by
2116
+ * half the fitted width along the label's local +x for `"start"` (text
2117
+ * grows away from the anchor) or local −x for `"end"` (text grows back
2118
+ * toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
2119
+ * screen (y-down) convention this offset needs.
2120
+ */
2121
+ function fitLabelTransformBox(input) {
2122
+ const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
2123
+ if (!text) return null;
2124
+ const angle = normalizeAngle(Math.PI - (rotation ?? 0) - (bearing ?? 0));
2125
+ const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
2126
+ const lines = text.split("\n");
2127
+ const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
2128
+ const height = lines.length * LINE_HEIGHT_FACTOR * h;
2129
+ const halfWidth = width / 2 + 4;
2130
+ const halfHeight = height / 2 + 4;
2131
+ let center = anchor;
2132
+ if (textAnchor === "start" || textAnchor === "end") {
2133
+ const [ox, oy] = rotate((textAnchor === "start" ? 1 : -1) * width / 2, 0, angle);
2134
+ center = [anchor[0] + ox, anchor[1] + oy];
2135
+ }
2136
+ return {
2137
+ center,
2138
+ halfWidth,
2139
+ halfHeight,
2140
+ angle
2141
+ };
2142
+ }
2051
2143
  //#endregion
2052
2144
  //#region src/tactical-draw/edit-controller.ts
2053
2145
  /**
@@ -2079,6 +2171,25 @@ const ALLOWED_EDIT_MODES = new Set([
2079
2171
  "delete"
2080
2172
  ]);
2081
2173
  /**
2174
+ * Field-wise merge of one `amplifierPlacements` entry: fields present in
2175
+ * `incoming` (valid position / finite rotation) replace the same field on
2176
+ * `existing`; fields absent from `incoming` are preserved. Returns the
2177
+ * *storage shape* for the merged entry — a bare `Position` when no rotation
2178
+ * survives (matching the legacy shorthand), the object form when a rotation
2179
+ * is present, or `undefined` when nothing survives.
2180
+ */
2181
+ function mergeAmplifierPlacementField(existing, incoming) {
2182
+ const merged = {
2183
+ ...resolveAmplifierPlacement(existing),
2184
+ ...resolveAmplifierPlacement(incoming)
2185
+ };
2186
+ if (merged.rotation === void 0) return merged.position ? [...merged.position] : void 0;
2187
+ return merged.position ? {
2188
+ position: [...merged.position],
2189
+ rotation: merged.rotation
2190
+ } : { rotation: merged.rotation };
2191
+ }
2192
+ /**
2082
2193
  * Drive an interactive edit against a `MapAdapter`. Implements the edit
2083
2194
  * lifecycle (issue #48) plus the ADR-0017 transform box:
2084
2195
  *
@@ -2161,6 +2272,22 @@ function runEdit(opts) {
2161
2272
  let box = null;
2162
2273
  /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
2163
2274
  let lastPreviewFeatures = [];
2275
+ /**
2276
+ * The currently selected label's `labelPlacementKey` in `labeldrag` mode, or
2277
+ * `null` when no label is selected. A click on a label toggles this (select
2278
+ * / deselect / switch — see `onDriverUp`'s amplifier-drag click path); it is
2279
+ * cleared whenever the feature disappears from `lastPreviewFeatures` or the
2280
+ * mode set drops `labeldrag` (see `applyModes`).
2281
+ */
2282
+ let selectedLabelKey = null;
2283
+ /**
2284
+ * The transform box fitted around the selected label (`label-transform-box.ts`),
2285
+ * re-fitted on every `renderHandlesLayer` call from `lastPreviewFeatures` —
2286
+ * except while a label rotate drag is in flight, when it is painted rigidly
2287
+ * from the drag's own live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`).
2288
+ * `null` when nothing is selected or the selection couldn't be fitted this frame.
2289
+ */
2290
+ let labelBox = null;
2164
2291
  let ctx;
2165
2292
  let driver;
2166
2293
  const dragProjection = {
@@ -2242,9 +2369,17 @@ function runEdit(opts) {
2242
2369
  if (modes.includes("labeldrag")) {
2243
2370
  const style = interactionStyle?.labelHandle;
2244
2371
  let index = 0;
2372
+ labelBox = null;
2373
+ if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
2374
+ else {
2375
+ const anchor = selectedLabelAnchor();
2376
+ if (selectedLabelKey && !anchor) selectedLabelKey = null;
2377
+ else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel);
2378
+ }
2245
2379
  for (const feature of lastPreviewFeatures) {
2246
2380
  const key = feature.properties?.labelPlacementKey;
2247
2381
  if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2382
+ if (key === selectedLabelKey) continue;
2248
2383
  const pixel = driver.toPixel(feature.geometry.coordinates);
2249
2384
  if (!pixel) continue;
2250
2385
  features.push({
@@ -2263,6 +2398,15 @@ function runEdit(opts) {
2263
2398
  });
2264
2399
  index += 1;
2265
2400
  }
2401
+ if (labelBox) {
2402
+ features.push(boxOutlineFeature(labelBox, dragProjection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
2403
+ const rotateHandle = {
2404
+ kind: "rotate",
2405
+ index: 0,
2406
+ position: dragProjection.fromPixel(rotateGripPixel(labelBox, 14))
2407
+ };
2408
+ features.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
2409
+ }
2266
2410
  }
2267
2411
  if (modes.includes("transform")) {
2268
2412
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
@@ -2527,8 +2671,15 @@ function runEdit(opts) {
2527
2671
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
2528
2672
  const previousRendered = rebuildRendered();
2529
2673
  const next = { ...workingAmplifierPlacements ?? {} };
2530
- for (const [field, position] of Object.entries(partial)) if (position === void 0) delete next[field];
2531
- else next[field] = [...position];
2674
+ for (const [field, value] of Object.entries(partial)) {
2675
+ if (value === void 0) {
2676
+ delete next[field];
2677
+ continue;
2678
+ }
2679
+ const merged = mergeAmplifierPlacementField(next[field], value);
2680
+ if (merged === void 0) delete next[field];
2681
+ else next[field] = merged;
2682
+ }
2532
2683
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2533
2684
  const currentRendered = rebuildRendered();
2534
2685
  renderPreview();
@@ -2596,6 +2747,7 @@ function runEdit(opts) {
2596
2747
  function applyModes(next) {
2597
2748
  if (modesEqual(modes, next)) return;
2598
2749
  modes = next;
2750
+ if (!modes.includes("labeldrag")) selectedLabelKey = null;
2599
2751
  renderHandlesLayer();
2600
2752
  }
2601
2753
  function drainPendingModes() {
@@ -2695,7 +2847,33 @@ function runEdit(opts) {
2695
2847
  }
2696
2848
  };
2697
2849
  let activeDrag = null;
2850
+ /**
2851
+ * A live label *move* drag: direct label grab, the floating grip, or a body
2852
+ * grab inside the selected label's transform box (ADR-0017 box machinery
2853
+ * applied to labels — see `label-transform-box.ts`) — all three funnel
2854
+ * through this one state, distinguished only by `grabOffset`. `grabOffset`
2855
+ * is the anchor-pixel-minus-down-pixel delta captured at pointer-down, so
2856
+ * `labelAnchorFromPointer` can re-derive the intended anchor from any
2857
+ * subsequent pointer pixel without the label snapping to the pointer's
2858
+ * exact position (a direct grab derives `[0, 0]`; the floating grip derives
2859
+ * `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab derives whatever offset the
2860
+ * click landed at within the box).
2861
+ */
2698
2862
  let activeAmplifierDrag = null;
2863
+ /** Read a label feature's rendered props into a `fitLabelTransformBox` input, anchored at `anchorPixel`. */
2864
+ function fitLabelBoxFromFeature(feature, anchorPixel) {
2865
+ const props = feature.properties ?? {};
2866
+ return fitLabelTransformBox({
2867
+ text: typeof props.text === "string" ? props.text : void 0,
2868
+ rotation: typeof props.rotation === "number" ? props.rotation : void 0,
2869
+ textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
2870
+ textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
2871
+ textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
2872
+ anchor: anchorPixel,
2873
+ resolution: adapter.getResolution(),
2874
+ bearing: adapter.getBearing()
2875
+ });
2876
+ }
2699
2877
  function pickLabel(pixel, tolerance) {
2700
2878
  for (const feature of lastPreviewFeatures) {
2701
2879
  const key = feature.properties?.labelPlacementKey;
@@ -2706,21 +2884,58 @@ function runEdit(opts) {
2706
2884
  const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
2707
2885
  if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
2708
2886
  key,
2709
- handle
2887
+ anchorPixel: projected
2710
2888
  };
2711
2889
  }
2890
+ const box = fitLabelBoxFromFeature(feature, projected);
2891
+ if (box && pointInBox(box, pixel)) return {
2892
+ key,
2893
+ anchorPixel: projected
2894
+ };
2712
2895
  }
2713
2896
  return null;
2714
2897
  }
2715
2898
  /**
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.
2899
+ * The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
2900
+ * `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
2901
+ * see `activeAmplifierDrag`), so the label keeps whatever offset it was
2902
+ * grabbed at instead of snapping to the pointer.
2719
2903
  */
2720
- function labelAnchorFromPointer(pixel, handle) {
2721
- return driver.fromPixel([pixel[0], pixel[1] + (handle ? LABEL_HANDLE_OFFSET_PX : 0)]);
2904
+ function labelAnchorFromPointer(pixel, grabOffset) {
2905
+ return driver.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
2906
+ }
2907
+ /**
2908
+ * The selected label's rendered feature + its current anchor pixel, or
2909
+ * `null` when nothing is selected, the feature is gone from this frame's
2910
+ * preview, or its anchor fails to project. The single lookup shared by the
2911
+ * box fit (`renderHandlesLayer`) and the box-pick paths (`onDriverDown`).
2912
+ */
2913
+ function selectedLabelAnchor() {
2914
+ if (!selectedLabelKey) return null;
2915
+ const feature = lastPreviewFeatures.find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
2916
+ if (!feature || feature.geometry?.type !== "Point") return null;
2917
+ const pixel = driver.toPixel(feature.geometry.coordinates);
2918
+ if (!pixel) return null;
2919
+ return {
2920
+ feature,
2921
+ pixel
2922
+ };
2923
+ }
2924
+ /** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
2925
+ function pointerAngleAround(pixel, anchor) {
2926
+ return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
2722
2927
  }
2723
2928
  /**
2929
+ * A live label *rotate* drag (ADR-0017 box machinery applied to a label's
2930
+ * rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
2931
+ * `textSizeMeters` are captured at pointer-down so `lastBox` can be
2932
+ * re-fitted purely from drag math on every move tick — independent of
2933
+ * `lastPreviewFeatures`, which mirrors `activeBoxDrag.lastSnapshot` and
2934
+ * keeps the box painted even if a transient preview render fails
2935
+ * (`renderPreview`'s try/catch).
2936
+ */
2937
+ let activeLabelRotateDrag = null;
2938
+ /**
2724
2939
  * A live transform-box gesture (corner scale, box rotate, or body
2725
2940
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
2726
2941
  * and is replaced on every `move()` tick; `renderHandlesLayer` paints
@@ -2768,11 +2983,47 @@ function runEdit(opts) {
2768
2983
  const onDriverDown = (pixel, info) => {
2769
2984
  if (ctx.settled || dragInFlight) return;
2770
2985
  const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2986
+ if (modes.includes("labeldrag") && labelBox) {
2987
+ const anchor = selectedLabelAnchor();
2988
+ if (anchor) {
2989
+ const gripPixel = rotateGripPixel(labelBox, 14);
2990
+ if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tol) {
2991
+ const props = anchor.feature.properties ?? {};
2992
+ activeLabelRotateDrag = {
2993
+ key: selectedLabelKey,
2994
+ anchorPixel: anchor.pixel,
2995
+ startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
2996
+ startRotation: typeof props.rotation === "number" ? props.rotation : 0,
2997
+ previous: snapshotAmplifierPlacements(),
2998
+ downPixel: pixel,
2999
+ text: typeof props.text === "string" ? props.text : void 0,
3000
+ textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
3001
+ textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
3002
+ textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
3003
+ lastBox: labelBox
3004
+ };
3005
+ dragInFlight = true;
3006
+ driver.setDragPanEnabled(false);
3007
+ return;
3008
+ }
3009
+ if (pointInBox(labelBox, pixel)) {
3010
+ activeAmplifierDrag = {
3011
+ field: selectedLabelKey,
3012
+ grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
3013
+ previous: snapshotAmplifierPlacements(),
3014
+ downPixel: pixel
3015
+ };
3016
+ dragInFlight = true;
3017
+ driver.setDragPanEnabled(false);
3018
+ return;
3019
+ }
3020
+ }
3021
+ }
2771
3022
  const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
2772
3023
  if (labelHit) {
2773
3024
  activeAmplifierDrag = {
2774
3025
  field: labelHit.key,
2775
- handle: labelHit.handle,
3026
+ grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
2776
3027
  previous: snapshotAmplifierPlacements(),
2777
3028
  downPixel: pixel
2778
3029
  };
@@ -2873,12 +3124,36 @@ function runEdit(opts) {
2873
3124
  }
2874
3125
  const onDriverMove = (pixel) => {
2875
3126
  if (ctx.settled) return;
2876
- if (activeAmplifierDrag) {
2877
- const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.handle);
3127
+ if (activeLabelRotateDrag) {
3128
+ const drag = activeLabelRotateDrag;
3129
+ const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3130
+ const rotation = drag.startRotation - delta;
3131
+ const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[drag.key], { rotation });
3132
+ workingAmplifierPlacements = {
3133
+ ...workingAmplifierPlacements ?? {},
3134
+ ...merged !== void 0 ? { [drag.key]: merged } : {}
3135
+ };
3136
+ const liveBox = fitLabelTransformBox({
3137
+ text: drag.text,
3138
+ rotation,
3139
+ textAnchor: drag.textAnchor,
3140
+ textSizePixels: drag.textSizePixels,
3141
+ textSizeMeters: drag.textSizeMeters,
3142
+ anchor: drag.anchorPixel,
3143
+ resolution: adapter.getResolution(),
3144
+ bearing: adapter.getBearing()
3145
+ });
3146
+ if (liveBox) drag.lastBox = liveBox;
3147
+ renderPreview();
3148
+ renderHandlesLayer();
3149
+ } else if (activeAmplifierDrag) {
3150
+ const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
2878
3151
  if (!position) return;
3152
+ const field = activeAmplifierDrag.field;
3153
+ const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
2879
3154
  workingAmplifierPlacements = {
2880
3155
  ...workingAmplifierPlacements ?? {},
2881
- [activeAmplifierDrag.field]: position
3156
+ ...merged !== void 0 ? { [field]: merged } : {}
2882
3157
  };
2883
3158
  renderPreview();
2884
3159
  renderHandlesLayer();
@@ -2892,17 +3167,35 @@ function runEdit(opts) {
2892
3167
  drainPendingModes();
2893
3168
  }
2894
3169
  const onDriverUp = (pixel) => {
3170
+ if (activeLabelRotateDrag) {
3171
+ const drag = activeLabelRotateDrag;
3172
+ activeLabelRotateDrag = null;
3173
+ workingAmplifierPlacements = drag.previous;
3174
+ endDragBookkeeping();
3175
+ if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$1) {
3176
+ const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3177
+ const rotation = drag.startRotation - delta;
3178
+ setAmplifierPlacements({ [drag.key]: { rotation } });
3179
+ } else {
3180
+ renderPreview();
3181
+ renderHandlesLayer();
3182
+ }
3183
+ suppressClickAway = true;
3184
+ return;
3185
+ }
2895
3186
  if (activeAmplifierDrag) {
2896
- const { field, handle, previous, downPixel } = activeAmplifierDrag;
3187
+ const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
2897
3188
  activeAmplifierDrag = null;
2898
- const position = labelAnchorFromPointer(pixel, handle);
3189
+ const position = labelAnchorFromPointer(pixel, grabOffset);
2899
3190
  workingAmplifierPlacements = previous;
2900
3191
  endDragBookkeeping();
2901
3192
  if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
2902
3193
  else {
3194
+ selectedLabelKey = selectedLabelKey === field ? null : field;
2903
3195
  renderPreview();
2904
3196
  renderHandlesLayer();
2905
3197
  }
3198
+ suppressClickAway = true;
2906
3199
  return;
2907
3200
  }
2908
3201
  if (activeDrag) {
@@ -3018,11 +3311,12 @@ function runEdit(opts) {
3018
3311
  unsubDown();
3019
3312
  unsubMove();
3020
3313
  unsubUp();
3021
- if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
3314
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
3022
3315
  driver.setDragPanEnabled(true);
3023
3316
  activeDrag = null;
3024
3317
  activeBoxDrag = null;
3025
3318
  activeAmplifierDrag = null;
3319
+ activeLabelRotateDrag = null;
3026
3320
  dragInFlight = false;
3027
3321
  }
3028
3322
  driver.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.17",
3
+ "version": "0.2.0-alpha.18",
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.13"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.14"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",