@orbat-mapper/tactical-draw 0.2.0-alpha.16 → 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
  /**
@@ -2024,6 +2033,114 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2024
2033
  };
2025
2034
  }
2026
2035
  //#endregion
2036
+ //#region src/tactical-draw/transform-amplifier-placements.ts
2037
+ /**
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 − Δ`.
2049
+ */
2050
+ function transformAmplifierPlacements(placements, from, to, projection) {
2051
+ if (!placements) return void 0;
2052
+ const scaleX = to.halfWidth / from.halfWidth;
2053
+ const scaleY = to.halfHeight / from.halfHeight;
2054
+ const angleDelta = to.angle - from.angle;
2055
+ const transformed = {};
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 };
2061
+ continue;
2062
+ }
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;
2073
+ }
2074
+ return transformed;
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
+ }
2143
+ //#endregion
2027
2144
  //#region src/tactical-draw/edit-controller.ts
2028
2145
  /**
2029
2146
  * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
@@ -2054,6 +2171,25 @@ const ALLOWED_EDIT_MODES = new Set([
2054
2171
  "delete"
2055
2172
  ]);
2056
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
+ /**
2057
2193
  * Drive an interactive edit against a `MapAdapter`. Implements the edit
2058
2194
  * lifecycle (issue #48) plus the ADR-0017 transform box:
2059
2195
  *
@@ -2136,6 +2272,22 @@ function runEdit(opts) {
2136
2272
  let box = null;
2137
2273
  /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
2138
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;
2139
2291
  let ctx;
2140
2292
  let driver;
2141
2293
  const dragProjection = {
@@ -2217,9 +2369,17 @@ function runEdit(opts) {
2217
2369
  if (modes.includes("labeldrag")) {
2218
2370
  const style = interactionStyle?.labelHandle;
2219
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
+ }
2220
2379
  for (const feature of lastPreviewFeatures) {
2221
2380
  const key = feature.properties?.labelPlacementKey;
2222
2381
  if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2382
+ if (key === selectedLabelKey) continue;
2223
2383
  const pixel = driver.toPixel(feature.geometry.coordinates);
2224
2384
  if (!pixel) continue;
2225
2385
  features.push({
@@ -2238,6 +2398,15 @@ function runEdit(opts) {
2238
2398
  });
2239
2399
  index += 1;
2240
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
+ }
2241
2410
  }
2242
2411
  if (modes.includes("transform")) {
2243
2412
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
@@ -2502,8 +2671,15 @@ function runEdit(opts) {
2502
2671
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
2503
2672
  const previousRendered = rebuildRendered();
2504
2673
  const next = { ...workingAmplifierPlacements ?? {} };
2505
- for (const [field, position] of Object.entries(partial)) if (position === void 0) delete next[field];
2506
- 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
+ }
2507
2683
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2508
2684
  const currentRendered = rebuildRendered();
2509
2685
  renderPreview();
@@ -2571,6 +2747,7 @@ function runEdit(opts) {
2571
2747
  function applyModes(next) {
2572
2748
  if (modesEqual(modes, next)) return;
2573
2749
  modes = next;
2750
+ if (!modes.includes("labeldrag")) selectedLabelKey = null;
2574
2751
  renderHandlesLayer();
2575
2752
  }
2576
2753
  function drainPendingModes() {
@@ -2670,7 +2847,33 @@ function runEdit(opts) {
2670
2847
  }
2671
2848
  };
2672
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
+ */
2673
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
+ }
2674
2877
  function pickLabel(pixel, tolerance) {
2675
2878
  for (const feature of lastPreviewFeatures) {
2676
2879
  const key = feature.properties?.labelPlacementKey;
@@ -2681,21 +2884,58 @@ function runEdit(opts) {
2681
2884
  const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
2682
2885
  if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
2683
2886
  key,
2684
- handle
2887
+ anchorPixel: projected
2685
2888
  };
2686
2889
  }
2890
+ const box = fitLabelBoxFromFeature(feature, projected);
2891
+ if (box && pointInBox(box, pixel)) return {
2892
+ key,
2893
+ anchorPixel: projected
2894
+ };
2687
2895
  }
2688
2896
  return null;
2689
2897
  }
2690
2898
  /**
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.
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.
2694
2903
  */
2695
- function labelAnchorFromPointer(pixel, handle) {
2696
- 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]]);
2697
2906
  }
2698
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]);
2927
+ }
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
+ /**
2699
2939
  * A live transform-box gesture (corner scale, box rotate, or body
2700
2940
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
2701
2941
  * and is replaced on every `move()` tick; `renderHandlesLayer` paints
@@ -2704,24 +2944,6 @@ function runEdit(opts) {
2704
2944
  * `boxAngle`.
2705
2945
  */
2706
2946
  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
- }
2725
2947
  /**
2726
2948
  * Whether the vertex at `index` may be deleted (via Alt+click or in the
2727
2949
  * exclusive `delete` mode). Deletion is
@@ -2761,11 +2983,47 @@ function runEdit(opts) {
2761
2983
  const onDriverDown = (pixel, info) => {
2762
2984
  if (ctx.settled || dragInFlight) return;
2763
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
+ }
2764
3022
  const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
2765
3023
  if (labelHit) {
2766
3024
  activeAmplifierDrag = {
2767
3025
  field: labelHit.key,
2768
- handle: labelHit.handle,
3026
+ grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
2769
3027
  previous: snapshotAmplifierPlacements(),
2770
3028
  downPixel: pixel
2771
3029
  };
@@ -2859,19 +3117,43 @@ function runEdit(opts) {
2859
3117
  const snapshot = activeBoxDrag.fsm.move(pixel);
2860
3118
  activeBoxDrag.lastSnapshot = snapshot;
2861
3119
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2862
- workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box);
3120
+ workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
2863
3121
  renderPreview();
2864
3122
  renderHandlesLayer();
2865
3123
  recomputeDirty();
2866
3124
  }
2867
3125
  const onDriverMove = (pixel) => {
2868
3126
  if (ctx.settled) return;
2869
- if (activeAmplifierDrag) {
2870
- 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);
2871
3151
  if (!position) return;
3152
+ const field = activeAmplifierDrag.field;
3153
+ const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
2872
3154
  workingAmplifierPlacements = {
2873
3155
  ...workingAmplifierPlacements ?? {},
2874
- [activeAmplifierDrag.field]: position
3156
+ ...merged !== void 0 ? { [field]: merged } : {}
2875
3157
  };
2876
3158
  renderPreview();
2877
3159
  renderHandlesLayer();
@@ -2885,17 +3167,35 @@ function runEdit(opts) {
2885
3167
  drainPendingModes();
2886
3168
  }
2887
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
+ }
2888
3186
  if (activeAmplifierDrag) {
2889
- const { field, handle, previous, downPixel } = activeAmplifierDrag;
3187
+ const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
2890
3188
  activeAmplifierDrag = null;
2891
- const position = labelAnchorFromPointer(pixel, handle);
3189
+ const position = labelAnchorFromPointer(pixel, grabOffset);
2892
3190
  workingAmplifierPlacements = previous;
2893
3191
  endDragBookkeeping();
2894
3192
  if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
2895
3193
  else {
3194
+ selectedLabelKey = selectedLabelKey === field ? null : field;
2896
3195
  renderPreview();
2897
3196
  renderHandlesLayer();
2898
3197
  }
3198
+ suppressClickAway = true;
2899
3199
  return;
2900
3200
  }
2901
3201
  if (activeDrag) {
@@ -2938,7 +3238,7 @@ function runEdit(opts) {
2938
3238
  replaceWorkingPoints(preDragPoints);
2939
3239
  workingAmplifierPlacements = preDragAmplifierPlacements;
2940
3240
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2941
- commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box));
3241
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
2942
3242
  return;
2943
3243
  }
2944
3244
  };
@@ -3011,11 +3311,12 @@ function runEdit(opts) {
3011
3311
  unsubDown();
3012
3312
  unsubMove();
3013
3313
  unsubUp();
3014
- if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
3314
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
3015
3315
  driver.setDragPanEnabled(true);
3016
3316
  activeDrag = null;
3017
3317
  activeBoxDrag = null;
3018
3318
  activeAmplifierDrag = null;
3319
+ activeLabelRotateDrag = null;
3019
3320
  dragInFlight = false;
3020
3321
  }
3021
3322
  driver.dispose();
@@ -3126,6 +3427,7 @@ function runGroupTransform(opts) {
3126
3427
  measure,
3127
3428
  editStartPoints,
3128
3429
  workingPoints: editStartPoints.map((p) => [...p]),
3430
+ workingAmplifierPlacements: measure.amplifierPlacements ? structuredClone(measure.amplifierPlacements) : void 0,
3129
3431
  optionsOverride: null,
3130
3432
  box: null,
3131
3433
  lastPreviewFeatures: []
@@ -3157,7 +3459,8 @@ function runGroupTransform(opts) {
3157
3459
  const working = {
3158
3460
  ...member.measure,
3159
3461
  options: mergeOptions(member.measure.options, override),
3160
- controlPoints: member.workingPoints.map((p) => [...p])
3462
+ controlPoints: member.workingPoints.map((p) => [...p]),
3463
+ ...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
3161
3464
  };
3162
3465
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
3163
3466
  return {
@@ -3171,7 +3474,8 @@ function runGroupTransform(opts) {
3171
3474
  return {
3172
3475
  ...member.measure,
3173
3476
  options: effectiveOptions(i),
3174
- controlPoints: member.workingPoints.map((p) => [...p])
3477
+ controlPoints: member.workingPoints.map((p) => [...p]),
3478
+ ...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
3175
3479
  };
3176
3480
  }
3177
3481
  let dirty = false;
@@ -3338,6 +3642,21 @@ function runGroupTransform(opts) {
3338
3642
  member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
3339
3643
  });
3340
3644
  }
3645
+ function cloneAmplifierPlacementsByMember() {
3646
+ return members.map((member) => member.workingAmplifierPlacements ? structuredClone(member.workingAmplifierPlacements) : void 0);
3647
+ }
3648
+ /**
3649
+ * Adopts `placements[i]` by reference — callers hand over ownership (the
3650
+ * sources are freshly built transforms or snapshot arrays that are dead
3651
+ * after the restore), and working placements are only ever replaced
3652
+ * wholesale, never mutated in place. This runs on every pointermove of a
3653
+ * box drag, so it must not deep-clone.
3654
+ */
3655
+ function applyAmplifierPlacementsByMember(placements) {
3656
+ members.forEach((member, i) => {
3657
+ member.workingAmplifierPlacements = placements[i];
3658
+ });
3659
+ }
3341
3660
  function cloneOptionsOverrideByMember() {
3342
3661
  return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
3343
3662
  }
@@ -3369,7 +3688,7 @@ function runGroupTransform(opts) {
3369
3688
  * instance to all listeners in order, deferred `reject`/`close`/`abort`
3370
3689
  * (close/abort override reject), exception collection with `AggregateError`.
3371
3690
  */
3372
- function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
3691
+ function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
3373
3692
  if (ctx.settled) return;
3374
3693
  let rejectRequested = false;
3375
3694
  let closeRequested = false;
@@ -3399,6 +3718,7 @@ function runGroupTransform(opts) {
3399
3718
  else if (closeRequested) commitClose();
3400
3719
  else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
3401
3720
  restorePointsByMember(previousPointsByMember);
3721
+ applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
3402
3722
  sizeAnchor = previousSizeAnchor;
3403
3723
  nonGeometryDirty = previousNonGeometryDirty;
3404
3724
  applyOptionsOverrides(previousOptionsOverrideByMember);
@@ -3431,17 +3751,20 @@ function runGroupTransform(opts) {
3431
3751
  }
3432
3752
  return snapshots;
3433
3753
  }
3434
- function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
3754
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
3435
3755
  if (ctx.settled) return;
3436
3756
  const previousPointsByMember = clonePointsByMember();
3757
+ const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3437
3758
  const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
3438
3759
  const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
3439
3760
  applyConcatenatedPoints(nextConcatPoints);
3761
+ if (nextAmplifierPlacementsByMember) applyAmplifierPlacementsByMember(nextAmplifierPlacementsByMember);
3440
3762
  let currentRendered;
3441
3763
  try {
3442
3764
  currentRendered = rebuildRenderedAll();
3443
3765
  } catch (err) {
3444
3766
  restorePointsByMember(previousPointsByMember);
3767
+ applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
3445
3768
  dragInFlight = false;
3446
3769
  throw err;
3447
3770
  }
@@ -3449,7 +3772,7 @@ function runGroupTransform(opts) {
3449
3772
  renderHandlesLayer();
3450
3773
  recomputeDirty();
3451
3774
  dragInFlight = false;
3452
- emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3775
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3453
3776
  }
3454
3777
  function setSizeAnchor(next) {
3455
3778
  if (ctx.settled || sizeAnchor === next) return;
@@ -3457,12 +3780,13 @@ function runGroupTransform(opts) {
3457
3780
  const previousSizeAnchor = sizeAnchor;
3458
3781
  const previousNonGeometryDirty = nonGeometryDirty;
3459
3782
  const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
3783
+ const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3460
3784
  const previousRendered = rebuildRenderedAll();
3461
3785
  sizeAnchor = next;
3462
3786
  const currentRendered = rebuildRenderedAll();
3463
3787
  nonGeometryDirty = true;
3464
3788
  dirty = true;
3465
- emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3789
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3466
3790
  }
3467
3791
  /**
3468
3792
  * Cancel an in-flight box drag without committing it: restore the pre-drag
@@ -3474,10 +3798,11 @@ function runGroupTransform(opts) {
3474
3798
  */
3475
3799
  function cancelActiveBoxDrag() {
3476
3800
  if (!activeBoxDrag) return;
3477
- const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
3801
+ const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember } = activeBoxDrag;
3478
3802
  activeBoxDrag = null;
3479
3803
  driver.setDragPanEnabled(true);
3480
3804
  applyConcatenatedPoints(preDragPoints);
3805
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3481
3806
  applyOptionsOverrides(preDragOptionsOverrideByMember);
3482
3807
  dragInFlight = false;
3483
3808
  }
@@ -3567,6 +3892,7 @@ function runGroupTransform(opts) {
3567
3892
  renderHandlesLayer();
3568
3893
  }
3569
3894
  const preDragPoints = concatWorkingPoints();
3895
+ const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3570
3896
  activeBoxDrag = {
3571
3897
  fsm: beginBoxDrag({
3572
3898
  kind: boxHit.kind,
@@ -3583,7 +3909,8 @@ function runGroupTransform(opts) {
3583
3909
  points: preDragPoints,
3584
3910
  box: groupBox
3585
3911
  },
3586
- preDragOptionsOverrideByMember
3912
+ preDragOptionsOverrideByMember,
3913
+ preDragAmplifierPlacementsByMember
3587
3914
  };
3588
3915
  dragInFlight = true;
3589
3916
  driver.setDragPanEnabled(false);
@@ -3593,6 +3920,7 @@ function runGroupTransform(opts) {
3593
3920
  const snapshot = activeBoxDrag.fsm.move(pixel);
3594
3921
  activeBoxDrag.lastSnapshot = snapshot;
3595
3922
  applyConcatenatedPoints(snapshot.points);
3923
+ applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
3596
3924
  renderAllPreviews();
3597
3925
  renderHandlesLayer();
3598
3926
  recomputeDirty();
@@ -3609,12 +3937,13 @@ function runGroupTransform(opts) {
3609
3937
  driver.setDragPanEnabled(true);
3610
3938
  return;
3611
3939
  }
3612
- const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
3940
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember } = activeBoxDrag;
3613
3941
  activeBoxDrag = null;
3614
3942
  driver.setDragPanEnabled(true);
3615
3943
  const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
3616
3944
  if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
3617
3945
  applyConcatenatedPoints(preDragPoints);
3946
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3618
3947
  renderAllPreviews();
3619
3948
  renderHandlesLayer();
3620
3949
  recomputeDirty();
@@ -3624,8 +3953,9 @@ function runGroupTransform(opts) {
3624
3953
  }
3625
3954
  const finalSnapshot = fsm.move(pixel);
3626
3955
  applyConcatenatedPoints(preDragPoints);
3956
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3627
3957
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3628
- commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
3958
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)));
3629
3959
  };
3630
3960
  let pickPreview;
3631
3961
  let pickHandles;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.16",
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.12"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.14"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",