@orbat-mapper/tactical-draw 0.2.0-alpha.18 → 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
@@ -260,6 +260,13 @@ interface MapAdapter {
260
260
  * destruction.
261
261
  */
262
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[];
263
270
  }
264
271
  //#endregion
265
272
  //#region src/base-map-adapter.d.ts
@@ -339,6 +346,7 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
339
346
  * (MapLibre, OpenLayers) override this.
340
347
  */
341
348
  getBearing(): number;
349
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
342
350
  }
343
351
  //#endregion
344
352
  //#region src/pick-utils.d.ts
@@ -387,7 +395,7 @@ type ToPixel = (lonLat: Position) => PixelCoordinate | null;
387
395
  *
388
396
  * Returns the first matching feature in iteration order, or null.
389
397
  */
390
- 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;
391
399
  //#endregion
392
400
  //#region src/gestures/hit-tolerance.d.ts
393
401
  declare const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -993,6 +1001,18 @@ interface TacticalDrawLayerOptions {
993
1001
  guide?: LayerId;
994
1002
  handles?: LayerId;
995
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
+ }
996
1016
  interface TacticalDrawOptions {
997
1017
  layers?: TacticalDrawLayerOptions;
998
1018
  /**
@@ -1414,6 +1434,47 @@ declare class TacticalDraw {
1414
1434
  * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
1415
1435
  */
1416
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;
1417
1478
  /**
1418
1479
  * Abort the in-flight draw or edit. The default `"session"` reason matches
1419
1480
  * `DrawSession.abort()` / `EditSession.abort()`.
@@ -1543,4 +1604,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1543
1604
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1544
1605
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1545
1606
  //#endregion
1546
- 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
@@ -180,6 +180,9 @@ var BaseMapAdapter = class {
180
180
  getBearing() {
181
181
  return 0;
182
182
  }
183
+ getLayerFeatures(layerId) {
184
+ return this.featureCache.get(layerId) ?? [];
185
+ }
183
186
  };
184
187
  function pickHitTolerance(originalEvent) {
185
188
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
@@ -2140,6 +2143,28 @@ function fitLabelTransformBox(input) {
2140
2143
  angle
2141
2144
  };
2142
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
+ }
2143
2168
  //#endregion
2144
2169
  //#region src/tactical-draw/edit-controller.ts
2145
2170
  /**
@@ -2374,7 +2399,10 @@ function runEdit(opts) {
2374
2399
  else {
2375
2400
  const anchor = selectedLabelAnchor();
2376
2401
  if (selectedLabelKey && !anchor) selectedLabelKey = null;
2377
- else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel);
2402
+ else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel, {
2403
+ resolution: adapter.getResolution(),
2404
+ bearing: adapter.getBearing()
2405
+ });
2378
2406
  }
2379
2407
  for (const feature of lastPreviewFeatures) {
2380
2408
  const key = feature.properties?.labelPlacementKey;
@@ -2860,20 +2888,6 @@ function runEdit(opts) {
2860
2888
  * click landed at within the box).
2861
2889
  */
2862
2890
  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
- }
2877
2891
  function pickLabel(pixel, tolerance) {
2878
2892
  for (const feature of lastPreviewFeatures) {
2879
2893
  const key = feature.properties?.labelPlacementKey;
@@ -2887,7 +2901,10 @@ function runEdit(opts) {
2887
2901
  anchorPixel: projected
2888
2902
  };
2889
2903
  }
2890
- const box = fitLabelBoxFromFeature(feature, projected);
2904
+ const box = fitLabelBoxFromFeature(feature, projected, {
2905
+ resolution: adapter.getResolution(),
2906
+ bearing: adapter.getBearing()
2907
+ });
2891
2908
  if (box && pointInBox(box, pixel)) return {
2892
2909
  key,
2893
2910
  anchorPixel: projected
@@ -4640,6 +4657,92 @@ var TacticalDraw = class {
4640
4657
  return remove;
4641
4658
  }
4642
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
+ /**
4643
4746
  * Abort the in-flight draw or edit. The default `"session"` reason matches
4644
4747
  * `DrawSession.abort()` / `EditSession.abort()`.
4645
4748
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.18",
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",