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

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
  */
@@ -254,6 +260,13 @@ interface MapAdapter {
254
260
  * destruction.
255
261
  */
256
262
  destroy(): void;
263
+ /**
264
+ * Returns the features currently set on the layer — the adapter-side cache
265
+ * of the last `setLayerFeatures`/`updateLayerFeatures`/`removeFeatures`/
266
+ * `clearLayer` state. Empty array for an unknown layer. Callers must not
267
+ * mutate the returned array or features.
268
+ */
269
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
257
270
  }
258
271
  //#endregion
259
272
  //#region src/base-map-adapter.d.ts
@@ -328,6 +341,12 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
328
341
  abstract setDoubleClickZoomEnabled(enabled: boolean): void;
329
342
  abstract getResolution(): number | undefined;
330
343
  abstract getZoom(): number | undefined;
344
+ /**
345
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
346
+ * (MapLibre, OpenLayers) override this.
347
+ */
348
+ getBearing(): number;
349
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
331
350
  }
332
351
  //#endregion
333
352
  //#region src/pick-utils.d.ts
@@ -376,7 +395,7 @@ type ToPixel = (lonLat: Position) => PixelCoordinate | null;
376
395
  *
377
396
  * Returns the first matching feature in iteration order, or null.
378
397
  */
379
- declare function hitTestFeature(pixel: PixelCoordinate, features: Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
398
+ declare function hitTestFeature(pixel: PixelCoordinate, features: readonly Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
380
399
  //#endregion
381
400
  //#region src/gestures/hit-tolerance.d.ts
382
401
  declare const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -620,8 +639,15 @@ interface EditSession {
620
639
  * silent no-op after the session has settled (like [[setOptions]]).
621
640
  */
622
641
  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;
642
+ /**
643
+ * Merge field-keyed label overrides (position and/or rotation). The merge is
644
+ * **field-wise per key**: a bare `Position` value means `{ position }`;
645
+ * fields absent from the incoming value (e.g. `rotation` when only
646
+ * `position` is supplied) are preserved from the existing entry; an
647
+ * explicit `undefined` value deletes the key entirely — a full reset to the
648
+ * doctrinal placement (both position and rotation).
649
+ */
650
+ setAmplifierPlacements(partial: Record<string, AmplifierPlacement | undefined>): void;
625
651
  /**
626
652
  * Subscribe to working-copy changes. The listener receives an
627
653
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
@@ -975,6 +1001,18 @@ interface TacticalDrawLayerOptions {
975
1001
  guide?: LayerId;
976
1002
  handles?: LayerId;
977
1003
  }
1004
+ /**
1005
+ * Result of {@link TacticalDraw.ownsInteractionAt} — which layer claimed the
1006
+ * pixel and the feature that matched.
1007
+ */
1008
+ interface InteractionHit {
1009
+ /** Which TacticalDraw layer owns the pixel — topmost match wins. */
1010
+ layer: "handles" | "preview" | "graphics";
1011
+ /** The hit feature (do not mutate). */
1012
+ feature: Feature;
1013
+ /** Control-measure id when the hit feature belongs to one (graphics/preview parts). */
1014
+ measureId?: string;
1015
+ }
978
1016
  interface TacticalDrawOptions {
979
1017
  layers?: TacticalDrawLayerOptions;
980
1018
  /**
@@ -1396,6 +1434,47 @@ declare class TacticalDraw {
1396
1434
  * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
1397
1435
  */
1398
1436
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
1437
+ /**
1438
+ * Stateless hit test answering "does TacticalDraw own the pixel at
1439
+ * `pixel`?" — for hosts composing their own click-away / deselection logic
1440
+ * (issue #216) alongside `TacticalDraw`'s draw/edit sessions. Today hosts
1441
+ * hack this together from the `@internal` `layerIds` plus engine-specific
1442
+ * layer queries; this is the supported replacement.
1443
+ *
1444
+ * Tests, topmost first, the three interactive layers — `handlesLayer`,
1445
+ * then `previewLayer`, then `graphicsLayer` — against each layer's current
1446
+ * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
1447
+ * excluded: it is non-interactive draw-guide scaffolding, never a
1448
+ * legitimate "owned" click target.
1449
+ *
1450
+ * `preview` and `graphics` each get a label text-extent fallback after the
1451
+ * geometry pass misses on that layer: for every `Point` feature carrying a
1452
+ * string `properties.text`, the anchor is projected and
1453
+ * `fitLabelBoxFromFeature` (shared with the edit controller's `pickLabel`,
1454
+ * see commit f908434) estimates the rendered text's box; a pixel inside
1455
+ * that box counts as owned even though it's off the anchor point itself.
1456
+ * This matters most for the active preview, since a click can land
1457
+ * anywhere across a rotated/offset label during an edit.
1458
+ *
1459
+ * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
1460
+ * pass the host's raw click event as `originalEvent` to get the same
1461
+ * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
1462
+ * pass an explicit `tolerance` to override it outright.
1463
+ *
1464
+ * `measureId` is set when the hit feature resolves to a control-measure id
1465
+ * via `tryControlMeasureIdFromFeature` — present for graphics/preview
1466
+ * parts, generally absent for handles.
1467
+ *
1468
+ * This is a pure synchronous query over adapter-cached state: it does not
1469
+ * depend on `onPick`/`"click"` dispatch having run first, so it is safe to
1470
+ * call from any host click handler regardless of ordering relative to
1471
+ * TacticalDraw's own pick/click pipeline.
1472
+ */
1473
+ ownsInteractionAt(pixel: PixelCoordinate, options?: {
1474
+ tolerance?: number;
1475
+ originalEvent?: unknown;
1476
+ }): InteractionHit | null;
1477
+ private toInteractionHit;
1399
1478
  /**
1400
1479
  * Abort the in-flight draw or edit. The default `"session"` reason matches
1401
1480
  * `DrawSession.abort()` / `EditSession.abort()`.
@@ -1525,4 +1604,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1525
1604
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1526
1605
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1527
1606
  //#endregion
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 };
1607
+ 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 InteractionHit, 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
@@ -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,16 @@ 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
+ }
183
+ getLayerFeatures(layerId) {
184
+ return this.featureCache.get(layerId) ?? [];
185
+ }
176
186
  };
177
187
  function pickHitTolerance(originalEvent) {
178
188
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
@@ -1688,13 +1698,15 @@ function boxCorners(box) {
1688
1698
  });
1689
1699
  }
1690
1700
  /**
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.
1701
+ * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
1702
+ * above the top-edge midpoint, along the box's local "up" direction, so the
1703
+ * grip stays glued to the box (rotates with it) rather than floating at a
1704
+ * fixed screen offset. The label transform box passes its own tighter
1705
+ * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
1694
1706
  */
1695
- function rotateGripPixel(box) {
1707
+ function rotateGripPixel(box, offset = 28) {
1696
1708
  const { center, halfHeight, angle } = box;
1697
- const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
1709
+ const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
1698
1710
  return [center[0] + rx, center[1] + ry];
1699
1711
  }
1700
1712
  /**
@@ -2026,28 +2038,133 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2026
2038
  //#endregion
2027
2039
  //#region src/tactical-draw/transform-amplifier-placements.ts
2028
2040
  /**
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.
2041
+ * Carry user-adjusted label anchors (and rotation overrides) through a
2042
+ * transform-box gesture. Anchors are transformed in pixel space alongside the
2043
+ * control points so the result remains visually rigid across map
2044
+ * projections.
2045
+ *
2046
+ * Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
2047
+ * clockwise on screen (see `transform-box.ts`). Adapters render a label's
2048
+ * on-screen clockwise angle as `π − rotation`, and a rotation delta is
2049
+ * invariant under the map→screen bearing, so an on-screen delta
2050
+ * `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
2051
+ * i.e. `rotation' = rotation − Δ`.
2032
2052
  */
2033
2053
  function transformAmplifierPlacements(placements, from, to, projection) {
2034
2054
  if (!placements) return void 0;
2035
2055
  const scaleX = to.halfWidth / from.halfWidth;
2036
2056
  const scaleY = to.halfHeight / from.halfHeight;
2057
+ const angleDelta = to.angle - from.angle;
2037
2058
  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];
2059
+ for (const [field, raw] of Object.entries(placements)) {
2060
+ const resolved = resolveAmplifierPlacement(raw);
2061
+ const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
2062
+ if (!resolved.position) {
2063
+ if (rotation !== void 0) transformed[field] = { rotation };
2043
2064
  continue;
2044
2065
  }
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]);
2066
+ const pixel = projection.toPixel(resolved.position);
2067
+ const position = pixel ? (() => {
2068
+ const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
2069
+ const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
2070
+ return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
2071
+ })() : [...resolved.position];
2072
+ transformed[field] = rotation !== void 0 ? {
2073
+ position,
2074
+ rotation
2075
+ } : position;
2048
2076
  }
2049
2077
  return transformed;
2050
2078
  }
2079
+ /** Estimated glyph width as a fraction of text height — a monospace-ish approximation, not a real text-metrics measurement. */
2080
+ const CHAR_WIDTH_FACTOR = .6;
2081
+ /** Estimated line height as a multiple of text height. */
2082
+ const LINE_HEIGHT_FACTOR = 1.2;
2083
+ /**
2084
+ * Normalize `angle` (radians) into `(-π, π]`, matching the range
2085
+ * `Math.atan2`/screen-angle math elsewhere in `tactical-draw` produces.
2086
+ */
2087
+ function normalizeAngle(angle) {
2088
+ const twoPi = Math.PI * 2;
2089
+ let a = angle % twoPi;
2090
+ if (a <= -Math.PI) a += twoPi;
2091
+ else if (a > Math.PI) a -= twoPi;
2092
+ return a;
2093
+ }
2094
+ /**
2095
+ * Fit a `TransformBox` around `input`'s label. Returns `null` when `text` is
2096
+ * empty/absent — there is nothing to box.
2097
+ *
2098
+ * - **Angle**: on-screen clockwise, derived from the stored `rotation` by the
2099
+ * same `π − rotation` formula adapters use to render the label (see
2100
+ * `ml-adapter.ts`'s `buildSymbolLayer`), further offset by `−bearing` since
2101
+ * labels render map-aligned (`text-rotation-alignment: "map"` / OL
2102
+ * `rotateWithView: true`) and rotate with the view, so the box tilts
2103
+ * exactly as the rendered text does.
2104
+ * - **Size**: `text` is split on `"\n"`; the longest line's character count
2105
+ * times `CHAR_WIDTH_FACTOR × h` gives the width, the line count times
2106
+ * `LINE_HEIGHT_FACTOR × h` gives the height, where `h` is the resolved
2107
+ * text-height-in-pixels, mirroring how the adapters render each anchor
2108
+ * kind (ADR-0028): screen-anchored `textSizePixels` is used as-is,
2109
+ * unclamped (only a `1px` sanity floor to keep the box non-degenerate);
2110
+ * ground-anchored `textSizeMeters / resolution` is clamped to the default
2111
+ * `[DEFAULT_TEXT_SIZE_MIN_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS]` band via
2112
+ * `resolveGroundTextSizePixels` (`feature-style.ts`); absent both,
2113
+ * `DEFAULT_LABEL_TEXT_SIZE_PX`. `LABEL_BOX_PADDING_PX` is added on every
2114
+ * side. Hosts can override the clamp band per layer style
2115
+ * (`TextStyleOptions.textSizeMinPixels`/`textSizeMaxPixels`), which this
2116
+ * estimate can't see — the default band keeps the box honest at extreme
2117
+ * zooms even when a host has customized it.
2118
+ * - **Center**: `anchor` for centered text (`textAnchor` absent); offset by
2119
+ * half the fitted width along the label's local +x for `"start"` (text
2120
+ * grows away from the anchor) or local −x for `"end"` (text grows back
2121
+ * toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
2122
+ * screen (y-down) convention this offset needs.
2123
+ */
2124
+ function fitLabelTransformBox(input) {
2125
+ const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
2126
+ if (!text) return null;
2127
+ const angle = normalizeAngle(Math.PI - (rotation ?? 0) - (bearing ?? 0));
2128
+ const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
2129
+ const lines = text.split("\n");
2130
+ const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
2131
+ const height = lines.length * LINE_HEIGHT_FACTOR * h;
2132
+ const halfWidth = width / 2 + 4;
2133
+ const halfHeight = height / 2 + 4;
2134
+ let center = anchor;
2135
+ if (textAnchor === "start" || textAnchor === "end") {
2136
+ const [ox, oy] = rotate((textAnchor === "start" ? 1 : -1) * width / 2, 0, angle);
2137
+ center = [anchor[0] + ox, anchor[1] + oy];
2138
+ }
2139
+ return {
2140
+ center,
2141
+ halfWidth,
2142
+ halfHeight,
2143
+ angle
2144
+ };
2145
+ }
2146
+ /**
2147
+ * Read a label `Point` feature's rendered props (`text`, `rotation`,
2148
+ * `textAnchor`, `textSizePixels`, `textSizeMeters`) into a
2149
+ * {@link fitLabelTransformBox} input, anchored at `anchorPixel`. Shared by
2150
+ * the edit controller's label picking/dragging and `TacticalDraw`'s
2151
+ * `ownsInteractionAt` label-extent fallback — both need the exact same
2152
+ * text-extent estimate so a click that "owns" a label during a host
2153
+ * click-away check matches what the edit controller would pick.
2154
+ */
2155
+ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
2156
+ const props = feature.properties ?? {};
2157
+ return fitLabelTransformBox({
2158
+ text: typeof props.text === "string" ? props.text : void 0,
2159
+ rotation: typeof props.rotation === "number" ? props.rotation : void 0,
2160
+ textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
2161
+ textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
2162
+ textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
2163
+ anchor: anchorPixel,
2164
+ resolution: view.resolution,
2165
+ bearing: view.bearing
2166
+ });
2167
+ }
2051
2168
  //#endregion
2052
2169
  //#region src/tactical-draw/edit-controller.ts
2053
2170
  /**
@@ -2079,6 +2196,25 @@ const ALLOWED_EDIT_MODES = new Set([
2079
2196
  "delete"
2080
2197
  ]);
2081
2198
  /**
2199
+ * Field-wise merge of one `amplifierPlacements` entry: fields present in
2200
+ * `incoming` (valid position / finite rotation) replace the same field on
2201
+ * `existing`; fields absent from `incoming` are preserved. Returns the
2202
+ * *storage shape* for the merged entry — a bare `Position` when no rotation
2203
+ * survives (matching the legacy shorthand), the object form when a rotation
2204
+ * is present, or `undefined` when nothing survives.
2205
+ */
2206
+ function mergeAmplifierPlacementField(existing, incoming) {
2207
+ const merged = {
2208
+ ...resolveAmplifierPlacement(existing),
2209
+ ...resolveAmplifierPlacement(incoming)
2210
+ };
2211
+ if (merged.rotation === void 0) return merged.position ? [...merged.position] : void 0;
2212
+ return merged.position ? {
2213
+ position: [...merged.position],
2214
+ rotation: merged.rotation
2215
+ } : { rotation: merged.rotation };
2216
+ }
2217
+ /**
2082
2218
  * Drive an interactive edit against a `MapAdapter`. Implements the edit
2083
2219
  * lifecycle (issue #48) plus the ADR-0017 transform box:
2084
2220
  *
@@ -2161,6 +2297,22 @@ function runEdit(opts) {
2161
2297
  let box = null;
2162
2298
  /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
2163
2299
  let lastPreviewFeatures = [];
2300
+ /**
2301
+ * The currently selected label's `labelPlacementKey` in `labeldrag` mode, or
2302
+ * `null` when no label is selected. A click on a label toggles this (select
2303
+ * / deselect / switch — see `onDriverUp`'s amplifier-drag click path); it is
2304
+ * cleared whenever the feature disappears from `lastPreviewFeatures` or the
2305
+ * mode set drops `labeldrag` (see `applyModes`).
2306
+ */
2307
+ let selectedLabelKey = null;
2308
+ /**
2309
+ * The transform box fitted around the selected label (`label-transform-box.ts`),
2310
+ * re-fitted on every `renderHandlesLayer` call from `lastPreviewFeatures` —
2311
+ * except while a label rotate drag is in flight, when it is painted rigidly
2312
+ * from the drag's own live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`).
2313
+ * `null` when nothing is selected or the selection couldn't be fitted this frame.
2314
+ */
2315
+ let labelBox = null;
2164
2316
  let ctx;
2165
2317
  let driver;
2166
2318
  const dragProjection = {
@@ -2242,9 +2394,20 @@ function runEdit(opts) {
2242
2394
  if (modes.includes("labeldrag")) {
2243
2395
  const style = interactionStyle?.labelHandle;
2244
2396
  let index = 0;
2397
+ labelBox = null;
2398
+ if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
2399
+ else {
2400
+ const anchor = selectedLabelAnchor();
2401
+ if (selectedLabelKey && !anchor) selectedLabelKey = null;
2402
+ else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel, {
2403
+ resolution: adapter.getResolution(),
2404
+ bearing: adapter.getBearing()
2405
+ });
2406
+ }
2245
2407
  for (const feature of lastPreviewFeatures) {
2246
2408
  const key = feature.properties?.labelPlacementKey;
2247
2409
  if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2410
+ if (key === selectedLabelKey) continue;
2248
2411
  const pixel = driver.toPixel(feature.geometry.coordinates);
2249
2412
  if (!pixel) continue;
2250
2413
  features.push({
@@ -2263,6 +2426,15 @@ function runEdit(opts) {
2263
2426
  });
2264
2427
  index += 1;
2265
2428
  }
2429
+ if (labelBox) {
2430
+ features.push(boxOutlineFeature(labelBox, dragProjection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
2431
+ const rotateHandle = {
2432
+ kind: "rotate",
2433
+ index: 0,
2434
+ position: dragProjection.fromPixel(rotateGripPixel(labelBox, 14))
2435
+ };
2436
+ features.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
2437
+ }
2266
2438
  }
2267
2439
  if (modes.includes("transform")) {
2268
2440
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
@@ -2527,8 +2699,15 @@ function runEdit(opts) {
2527
2699
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
2528
2700
  const previousRendered = rebuildRendered();
2529
2701
  const next = { ...workingAmplifierPlacements ?? {} };
2530
- for (const [field, position] of Object.entries(partial)) if (position === void 0) delete next[field];
2531
- else next[field] = [...position];
2702
+ for (const [field, value] of Object.entries(partial)) {
2703
+ if (value === void 0) {
2704
+ delete next[field];
2705
+ continue;
2706
+ }
2707
+ const merged = mergeAmplifierPlacementField(next[field], value);
2708
+ if (merged === void 0) delete next[field];
2709
+ else next[field] = merged;
2710
+ }
2532
2711
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2533
2712
  const currentRendered = rebuildRendered();
2534
2713
  renderPreview();
@@ -2596,6 +2775,7 @@ function runEdit(opts) {
2596
2775
  function applyModes(next) {
2597
2776
  if (modesEqual(modes, next)) return;
2598
2777
  modes = next;
2778
+ if (!modes.includes("labeldrag")) selectedLabelKey = null;
2599
2779
  renderHandlesLayer();
2600
2780
  }
2601
2781
  function drainPendingModes() {
@@ -2695,6 +2875,18 @@ function runEdit(opts) {
2695
2875
  }
2696
2876
  };
2697
2877
  let activeDrag = null;
2878
+ /**
2879
+ * A live label *move* drag: direct label grab, the floating grip, or a body
2880
+ * grab inside the selected label's transform box (ADR-0017 box machinery
2881
+ * applied to labels — see `label-transform-box.ts`) — all three funnel
2882
+ * through this one state, distinguished only by `grabOffset`. `grabOffset`
2883
+ * is the anchor-pixel-minus-down-pixel delta captured at pointer-down, so
2884
+ * `labelAnchorFromPointer` can re-derive the intended anchor from any
2885
+ * subsequent pointer pixel without the label snapping to the pointer's
2886
+ * exact position (a direct grab derives `[0, 0]`; the floating grip derives
2887
+ * `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab derives whatever offset the
2888
+ * click landed at within the box).
2889
+ */
2698
2890
  let activeAmplifierDrag = null;
2699
2891
  function pickLabel(pixel, tolerance) {
2700
2892
  for (const feature of lastPreviewFeatures) {
@@ -2706,20 +2898,60 @@ function runEdit(opts) {
2706
2898
  const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
2707
2899
  if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
2708
2900
  key,
2709
- handle
2901
+ anchorPixel: projected
2710
2902
  };
2711
2903
  }
2904
+ const box = fitLabelBoxFromFeature(feature, projected, {
2905
+ resolution: adapter.getResolution(),
2906
+ bearing: adapter.getBearing()
2907
+ });
2908
+ if (box && pointInBox(box, pixel)) return {
2909
+ key,
2910
+ anchorPixel: projected
2911
+ };
2712
2912
  }
2713
2913
  return null;
2714
2914
  }
2715
2915
  /**
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.
2916
+ * The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
2917
+ * `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
2918
+ * see `activeAmplifierDrag`), so the label keeps whatever offset it was
2919
+ * grabbed at instead of snapping to the pointer.
2920
+ */
2921
+ function labelAnchorFromPointer(pixel, grabOffset) {
2922
+ return driver.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
2923
+ }
2924
+ /**
2925
+ * The selected label's rendered feature + its current anchor pixel, or
2926
+ * `null` when nothing is selected, the feature is gone from this frame's
2927
+ * preview, or its anchor fails to project. The single lookup shared by the
2928
+ * box fit (`renderHandlesLayer`) and the box-pick paths (`onDriverDown`).
2719
2929
  */
2720
- function labelAnchorFromPointer(pixel, handle) {
2721
- return driver.fromPixel([pixel[0], pixel[1] + (handle ? LABEL_HANDLE_OFFSET_PX : 0)]);
2930
+ function selectedLabelAnchor() {
2931
+ if (!selectedLabelKey) return null;
2932
+ const feature = lastPreviewFeatures.find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
2933
+ if (!feature || feature.geometry?.type !== "Point") return null;
2934
+ const pixel = driver.toPixel(feature.geometry.coordinates);
2935
+ if (!pixel) return null;
2936
+ return {
2937
+ feature,
2938
+ pixel
2939
+ };
2722
2940
  }
2941
+ /** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
2942
+ function pointerAngleAround(pixel, anchor) {
2943
+ return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
2944
+ }
2945
+ /**
2946
+ * A live label *rotate* drag (ADR-0017 box machinery applied to a label's
2947
+ * rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
2948
+ * `textSizeMeters` are captured at pointer-down so `lastBox` can be
2949
+ * re-fitted purely from drag math on every move tick — independent of
2950
+ * `lastPreviewFeatures`, which mirrors `activeBoxDrag.lastSnapshot` and
2951
+ * keeps the box painted even if a transient preview render fails
2952
+ * (`renderPreview`'s try/catch).
2953
+ */
2954
+ let activeLabelRotateDrag = null;
2723
2955
  /**
2724
2956
  * A live transform-box gesture (corner scale, box rotate, or body
2725
2957
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
@@ -2768,11 +3000,47 @@ function runEdit(opts) {
2768
3000
  const onDriverDown = (pixel, info) => {
2769
3001
  if (ctx.settled || dragInFlight) return;
2770
3002
  const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
3003
+ if (modes.includes("labeldrag") && labelBox) {
3004
+ const anchor = selectedLabelAnchor();
3005
+ if (anchor) {
3006
+ const gripPixel = rotateGripPixel(labelBox, 14);
3007
+ if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tol) {
3008
+ const props = anchor.feature.properties ?? {};
3009
+ activeLabelRotateDrag = {
3010
+ key: selectedLabelKey,
3011
+ anchorPixel: anchor.pixel,
3012
+ startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
3013
+ startRotation: typeof props.rotation === "number" ? props.rotation : 0,
3014
+ previous: snapshotAmplifierPlacements(),
3015
+ downPixel: pixel,
3016
+ text: typeof props.text === "string" ? props.text : void 0,
3017
+ textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
3018
+ textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
3019
+ textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
3020
+ lastBox: labelBox
3021
+ };
3022
+ dragInFlight = true;
3023
+ driver.setDragPanEnabled(false);
3024
+ return;
3025
+ }
3026
+ if (pointInBox(labelBox, pixel)) {
3027
+ activeAmplifierDrag = {
3028
+ field: selectedLabelKey,
3029
+ grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
3030
+ previous: snapshotAmplifierPlacements(),
3031
+ downPixel: pixel
3032
+ };
3033
+ dragInFlight = true;
3034
+ driver.setDragPanEnabled(false);
3035
+ return;
3036
+ }
3037
+ }
3038
+ }
2771
3039
  const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
2772
3040
  if (labelHit) {
2773
3041
  activeAmplifierDrag = {
2774
3042
  field: labelHit.key,
2775
- handle: labelHit.handle,
3043
+ grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
2776
3044
  previous: snapshotAmplifierPlacements(),
2777
3045
  downPixel: pixel
2778
3046
  };
@@ -2873,12 +3141,36 @@ function runEdit(opts) {
2873
3141
  }
2874
3142
  const onDriverMove = (pixel) => {
2875
3143
  if (ctx.settled) return;
2876
- if (activeAmplifierDrag) {
2877
- const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.handle);
3144
+ if (activeLabelRotateDrag) {
3145
+ const drag = activeLabelRotateDrag;
3146
+ const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3147
+ const rotation = drag.startRotation - delta;
3148
+ const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[drag.key], { rotation });
3149
+ workingAmplifierPlacements = {
3150
+ ...workingAmplifierPlacements ?? {},
3151
+ ...merged !== void 0 ? { [drag.key]: merged } : {}
3152
+ };
3153
+ const liveBox = fitLabelTransformBox({
3154
+ text: drag.text,
3155
+ rotation,
3156
+ textAnchor: drag.textAnchor,
3157
+ textSizePixels: drag.textSizePixels,
3158
+ textSizeMeters: drag.textSizeMeters,
3159
+ anchor: drag.anchorPixel,
3160
+ resolution: adapter.getResolution(),
3161
+ bearing: adapter.getBearing()
3162
+ });
3163
+ if (liveBox) drag.lastBox = liveBox;
3164
+ renderPreview();
3165
+ renderHandlesLayer();
3166
+ } else if (activeAmplifierDrag) {
3167
+ const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
2878
3168
  if (!position) return;
3169
+ const field = activeAmplifierDrag.field;
3170
+ const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
2879
3171
  workingAmplifierPlacements = {
2880
3172
  ...workingAmplifierPlacements ?? {},
2881
- [activeAmplifierDrag.field]: position
3173
+ ...merged !== void 0 ? { [field]: merged } : {}
2882
3174
  };
2883
3175
  renderPreview();
2884
3176
  renderHandlesLayer();
@@ -2892,17 +3184,35 @@ function runEdit(opts) {
2892
3184
  drainPendingModes();
2893
3185
  }
2894
3186
  const onDriverUp = (pixel) => {
3187
+ if (activeLabelRotateDrag) {
3188
+ const drag = activeLabelRotateDrag;
3189
+ activeLabelRotateDrag = null;
3190
+ workingAmplifierPlacements = drag.previous;
3191
+ endDragBookkeeping();
3192
+ if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$1) {
3193
+ const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3194
+ const rotation = drag.startRotation - delta;
3195
+ setAmplifierPlacements({ [drag.key]: { rotation } });
3196
+ } else {
3197
+ renderPreview();
3198
+ renderHandlesLayer();
3199
+ }
3200
+ suppressClickAway = true;
3201
+ return;
3202
+ }
2895
3203
  if (activeAmplifierDrag) {
2896
- const { field, handle, previous, downPixel } = activeAmplifierDrag;
3204
+ const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
2897
3205
  activeAmplifierDrag = null;
2898
- const position = labelAnchorFromPointer(pixel, handle);
3206
+ const position = labelAnchorFromPointer(pixel, grabOffset);
2899
3207
  workingAmplifierPlacements = previous;
2900
3208
  endDragBookkeeping();
2901
3209
  if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
2902
3210
  else {
3211
+ selectedLabelKey = selectedLabelKey === field ? null : field;
2903
3212
  renderPreview();
2904
3213
  renderHandlesLayer();
2905
3214
  }
3215
+ suppressClickAway = true;
2906
3216
  return;
2907
3217
  }
2908
3218
  if (activeDrag) {
@@ -3018,11 +3328,12 @@ function runEdit(opts) {
3018
3328
  unsubDown();
3019
3329
  unsubMove();
3020
3330
  unsubUp();
3021
- if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
3331
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
3022
3332
  driver.setDragPanEnabled(true);
3023
3333
  activeDrag = null;
3024
3334
  activeBoxDrag = null;
3025
3335
  activeAmplifierDrag = null;
3336
+ activeLabelRotateDrag = null;
3026
3337
  dragInFlight = false;
3027
3338
  }
3028
3339
  driver.dispose();
@@ -4346,6 +4657,92 @@ var TacticalDraw = class {
4346
4657
  return remove;
4347
4658
  }
4348
4659
  /**
4660
+ * Stateless hit test answering "does TacticalDraw own the pixel at
4661
+ * `pixel`?" — for hosts composing their own click-away / deselection logic
4662
+ * (issue #216) alongside `TacticalDraw`'s draw/edit sessions. Today hosts
4663
+ * hack this together from the `@internal` `layerIds` plus engine-specific
4664
+ * layer queries; this is the supported replacement.
4665
+ *
4666
+ * Tests, topmost first, the three interactive layers — `handlesLayer`,
4667
+ * then `previewLayer`, then `graphicsLayer` — against each layer's current
4668
+ * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
4669
+ * excluded: it is non-interactive draw-guide scaffolding, never a
4670
+ * legitimate "owned" click target.
4671
+ *
4672
+ * `preview` and `graphics` each get a label text-extent fallback after the
4673
+ * geometry pass misses on that layer: for every `Point` feature carrying a
4674
+ * string `properties.text`, the anchor is projected and
4675
+ * `fitLabelBoxFromFeature` (shared with the edit controller's `pickLabel`,
4676
+ * see commit f908434) estimates the rendered text's box; a pixel inside
4677
+ * that box counts as owned even though it's off the anchor point itself.
4678
+ * This matters most for the active preview, since a click can land
4679
+ * anywhere across a rotated/offset label during an edit.
4680
+ *
4681
+ * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
4682
+ * pass the host's raw click event as `originalEvent` to get the same
4683
+ * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
4684
+ * pass an explicit `tolerance` to override it outright.
4685
+ *
4686
+ * `measureId` is set when the hit feature resolves to a control-measure id
4687
+ * via `tryControlMeasureIdFromFeature` — present for graphics/preview
4688
+ * parts, generally absent for handles.
4689
+ *
4690
+ * This is a pure synchronous query over adapter-cached state: it does not
4691
+ * depend on `onPick`/`"click"` dispatch having run first, so it is safe to
4692
+ * call from any host click handler regardless of ordering relative to
4693
+ * TacticalDraw's own pick/click pipeline.
4694
+ */
4695
+ ownsInteractionAt(pixel, options) {
4696
+ this.assertAlive();
4697
+ const tolerance = options?.tolerance ?? mapEventHitTolerance(options?.originalEvent);
4698
+ const toPixel = (lonLat) => this.adapter.getPixelFromCoordinate(lonLat);
4699
+ const layers = [
4700
+ {
4701
+ layer: "handles",
4702
+ layerId: this.handlesLayer,
4703
+ labelFallback: false
4704
+ },
4705
+ {
4706
+ layer: "preview",
4707
+ layerId: this.previewLayer,
4708
+ labelFallback: true
4709
+ },
4710
+ {
4711
+ layer: "graphics",
4712
+ layerId: this.graphicsLayer,
4713
+ labelFallback: true
4714
+ }
4715
+ ];
4716
+ for (const { layer, layerId, labelFallback } of layers) {
4717
+ const features = this.adapter.getLayerFeatures(layerId);
4718
+ const hit = hitTestFeature(pixel, features, tolerance, toPixel);
4719
+ if (hit) return this.toInteractionHit(layer, hit);
4720
+ if (labelFallback) for (const feature of features) {
4721
+ if (feature.geometry?.type !== "Point") continue;
4722
+ if (typeof feature.properties?.text !== "string") continue;
4723
+ const anchorPixel = toPixel(feature.geometry.coordinates);
4724
+ if (!anchorPixel) continue;
4725
+ const box = fitLabelBoxFromFeature(feature, anchorPixel, {
4726
+ resolution: this.adapter.getResolution(),
4727
+ bearing: this.adapter.getBearing()
4728
+ });
4729
+ if (box && pointInBox(box, pixel)) return this.toInteractionHit(layer, feature);
4730
+ }
4731
+ }
4732
+ return null;
4733
+ }
4734
+ toInteractionHit(layer, feature) {
4735
+ const measureId = tryControlMeasureIdFromFeature(feature);
4736
+ return measureId != null ? {
4737
+ layer,
4738
+ feature,
4739
+ measureId
4740
+ } : {
4741
+ layer,
4742
+ feature
4743
+ };
4744
+ }
4745
+ /**
4349
4746
  * Abort the in-flight draw or edit. The default `"session"` reason matches
4350
4747
  * `DrawSession.abort()` / `EditSession.abort()`.
4351
4748
  *
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.19",
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",