@orbat-mapper/tactical-draw 0.2.0-alpha.31 → 0.2.0-alpha.33

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
@@ -166,6 +166,17 @@ interface IconResource {
166
166
  width: number;
167
167
  height: number;
168
168
  anchor: [number, number];
169
+ /**
170
+ * Direct visible mark used to rank icon picks, in unscaled canvas coordinates.
171
+ * Hits elsewhere in the full canvas measure their distance from this rectangle;
172
+ * omit it to preserve whole-canvas distance-zero behavior.
173
+ */
174
+ coreRect?: {
175
+ x: number;
176
+ y: number;
177
+ width: number;
178
+ height: number;
179
+ };
169
180
  }
170
181
  /**
171
182
  * Click-style hit event delivered by {@link MapAdapter.onPick}.
@@ -254,6 +265,9 @@ interface MapAdapter {
254
265
  * end of a pan or pinch-zoom gesture.
255
266
  *
256
267
  * Hit tolerance is pointer-type aware: 4 px for mouse/pen, 12 px for touch.
268
+ * Outline-only polygon interiors additionally have bounded inward selection
269
+ * reach from visible viewport-clipped boundaries: 24 px for mouse/pen and
270
+ * 36 px for touch. A visible solid or patterned fill owns its full interior.
257
271
  *
258
272
  * Returns an unsubscribe function. The returned function and the optional
259
273
  * `signal` are both honoured; either teardown path stops further events,
@@ -443,6 +457,8 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
443
457
  }
444
458
  //#endregion
445
459
  //#region src/pick-utils.d.ts
460
+ type PickPointerType = "mouse" | "pen" | "touch";
461
+ declare function pickPointerType(originalEvent: unknown): PickPointerType;
446
462
  declare function pickHitTolerance(originalEvent: unknown): number;
447
463
  declare function tryControlMeasureIdFromFeature(feature: Feature): string | null;
448
464
  /**
@@ -453,6 +469,119 @@ declare function tryControlMeasureIdFromFeature(feature: Feature): string | null
453
469
  */
454
470
  declare function bindAbortable(signal: AbortSignal | undefined, attach: () => () => void): () => void;
455
471
  //#endregion
472
+ //#region src/pick-candidates.d.ts
473
+ interface ScreenBounds {
474
+ minX: number;
475
+ minY: number;
476
+ maxX: number;
477
+ maxY: number;
478
+ }
479
+ interface RotatedScreenBounds {
480
+ center: PixelCoordinate;
481
+ halfWidth: number;
482
+ halfHeight: number;
483
+ angle: number;
484
+ }
485
+ type LabelExtent = ScreenBounds | RotatedScreenBounds;
486
+ interface ResolvedPickPresentation {
487
+ stroke?: {
488
+ color?: string;
489
+ width?: number;
490
+ dash?: readonly number[];
491
+ };
492
+ fill?: {
493
+ color?: string;
494
+ pattern?: string;
495
+ opacity: number;
496
+ };
497
+ point?: {
498
+ fillColor?: string;
499
+ radius?: number;
500
+ };
501
+ text?: TextStyleOptions;
502
+ }
503
+ type PickCandidateHitKind = "native" | "point" | "line" | "polygon-boundary" | "polygon-interior" | "icon" | "label";
504
+ interface PickCandidateHit {
505
+ kind: PickCandidateHitKind;
506
+ /** CSS-pixel distance to the visible mark, or null when only the engine knows the hit. */
507
+ distance: number | null;
508
+ }
509
+ /**
510
+ * Engine-neutral description of one rendered part eligible at a pointer location.
511
+ * This is deliberately richer than PickEvent but remains adapter-author plumbing:
512
+ * ordered candidates are not exposed by the public picking event.
513
+ */
514
+ interface PickCandidate {
515
+ feature: Feature;
516
+ graphicId: string | null;
517
+ renderedPartId: string | number | null;
518
+ hit: PickCandidateHit;
519
+ viewport: ScreenBounds;
520
+ presentation: ResolvedPickPresentation;
521
+ visualOrder: number;
522
+ iconFootprint?: readonly PixelCoordinate[];
523
+ labelExtent?: LabelExtent;
524
+ }
525
+ /** A rendered part supplied by an adapter or TacticalDraw for shared evaluation. */
526
+ interface PickCandidateInput {
527
+ feature: Feature;
528
+ /**
529
+ * Lower values mean visually topmost. Suppliers must normalize their
530
+ * native ordering to topmost-first before calling into evaluation.
531
+ */
532
+ visualOrder: number;
533
+ /** The engine already reported this part as hit; retained as a fallback for engine-only marks. */
534
+ nativeHit?: boolean;
535
+ icon?: IconResource;
536
+ labelExtent?: LabelExtent;
537
+ presentation?: ResolvedPickPresentation;
538
+ layerStyle?: StyleOptions;
539
+ }
540
+ /**
541
+ * The `visualOrder` for the feature at `index` of a render-ordered
542
+ * (bottom-first) list of `total` features: reversing the index makes the
543
+ * topmost-rendered feature the lowest, and therefore winning, order.
544
+ */
545
+ declare function renderOrderToVisualOrder(index: number, total: number): number;
546
+ interface MergedPickFeature {
547
+ feature: Feature;
548
+ visualOrder: number;
549
+ nativeHit: boolean;
550
+ }
551
+ /**
552
+ * Preserve native visual ordering/hit evidence while keeping cached source
553
+ * geometry authoritative. Native polygon geometry may be tile-clipped and
554
+ * therefore contain artificial edges that are not part of the graphic.
555
+ *
556
+ * Normalizes to the shared `visualOrder` contract (lower = visually
557
+ * topmost): native hits already arrive topmost-first from the engines and
558
+ * keep that order at the front. Cached-only features (not among the native
559
+ * hits) are appended after them, in reverse cache order — cache arrays are
560
+ * render order (bottom-first), so reversing yields topmost-first — with
561
+ * `visualOrder` numbering continuing on from the native section.
562
+ */
563
+ declare function mergeNativeAndCachedPickFeatures(nativeFeatures: readonly Feature[], cachedFeatures: readonly Feature[]): MergedPickFeature[];
564
+ interface PickEvaluationContext {
565
+ pixel: PixelCoordinate;
566
+ tolerance: number;
567
+ pointerType?: PickPointerType;
568
+ toPixel: (position: Position) => PixelCoordinate | null;
569
+ viewport: ScreenBounds;
570
+ bearing?: number;
571
+ }
572
+ /**
573
+ * Evaluate every relevant rendered part in screen space, then return the
574
+ * primary candidate under the visible-intent ranking policy: direct visible
575
+ * marks (strokes, points, icons, labels) outrank visibly filled interiors,
576
+ * which outrank inferred outline-only reach; competing direct marks rank by
577
+ * screen-space distance; visual topmost order is only the final tie-break.
578
+ * See `rankPickCandidates` for the full ordered list — that ordering stays
579
+ * internal adapter-author plumbing; the public pick event exposes only this
580
+ * primary candidate.
581
+ */
582
+ declare function evaluatePickCandidates(inputs: readonly PickCandidateInput[], context: PickEvaluationContext): PickCandidate | null;
583
+ declare function resolvePickPresentation(feature: Feature, layerStyle?: StyleOptions): ResolvedPickPresentation;
584
+ //#endregion
456
585
  //#region src/feature-style.d.ts
457
586
  /**
458
587
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -748,6 +877,15 @@ interface InteractionStyle {
748
877
  scaleHandle?: HandleStyle;
749
878
  rotateHandle?: HandleStyle;
750
879
  boxOutline?: GuideStyle;
880
+ /**
881
+ * Line style for the passive, non-interactive selection highlight rendered
882
+ * by {@link TacticalDraw.setHighlightedGraphics}. A dashed, muted variant of
883
+ * `boxOutline` so a locked/highlighted selection reads as visually distinct
884
+ * from an active edit's oriented transform box. Delivered per-feature via
885
+ * `properties.style` on the highlight outline `LineString`, resolved through
886
+ * the same built-in → façade → per-call slot system as every other field.
887
+ */
888
+ highlightOutline?: GuideStyle;
751
889
  /**
752
890
  * Pixel padding added on every side of the fitted transform box
753
891
  * (`fitTransformBox`, ADR-0017) so the box doesn't tightly hug the rendered
@@ -1418,6 +1556,12 @@ type DrawPointSymbolDraft = Pick<PointSymbol, "kind" | "sidc"> & Partial<Omit<Po
1418
1556
  */
1419
1557
  interface TacticalDrawLayerOptions {
1420
1558
  graphics?: LayerId;
1559
+ /**
1560
+ * Passive selection-highlight outlines ({@link TacticalDraw.setHighlightedGraphics}).
1561
+ * Resolved immediately above `graphics` so highlight boxes stack over base
1562
+ * graphics but below the preview / guide / handles interaction chrome.
1563
+ */
1564
+ highlight?: LayerId;
1421
1565
  preview?: LayerId;
1422
1566
  guide?: LayerId;
1423
1567
  handles?: LayerId;
@@ -1604,6 +1748,7 @@ interface DrawOptions {
1604
1748
  declare class TacticalDraw {
1605
1749
  private readonly adapter;
1606
1750
  private readonly graphicsLayer;
1751
+ private readonly highlightLayer;
1607
1752
  private readonly previewLayer;
1608
1753
  private readonly guideLayer;
1609
1754
  private readonly handlesLayer;
@@ -1665,6 +1810,20 @@ declare class TacticalDraw {
1665
1810
  */
1666
1811
  private lastRenderedMeasures;
1667
1812
  private snapGeometryFeatures;
1813
+ /**
1814
+ * Ids of graphics currently carrying a passive selection-highlight outline
1815
+ * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
1816
+ * re-projected on every view change and re-evaluated at the end of every
1817
+ * `render()` so outlines track geometry, style, and dropped graphics.
1818
+ */
1819
+ private highlightedIds;
1820
+ /**
1821
+ * Whether the highlight layer currently holds any outline. Lets
1822
+ * `renderHighlights` skip a redundant empty write on every view change /
1823
+ * render while no highlights are set, but still emit the one clearing write
1824
+ * that removes outlines when the set transitions to empty.
1825
+ */
1826
+ private hasHighlightOutlines;
1668
1827
  constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1669
1828
  /** Replace snapping configuration without recreating the map adapter or façade. */
1670
1829
  setSnappingOptions(options?: SnappingOptions): void;
@@ -1729,6 +1888,34 @@ declare class TacticalDraw {
1729
1888
  * pure style replay).
1730
1889
  */
1731
1890
  render(measures: readonly Graphic[]): void;
1891
+ /**
1892
+ * Replace the passive selection-highlight set with `ids`. Declarative and
1893
+ * idempotent (mirrors {@link render}): a non-interactive outline box is drawn
1894
+ * around each still-rendered graphic on the dedicated highlight layer — no
1895
+ * draw / edit / transform session, no promise, no pick subscription. Passing
1896
+ * `[]` clears every highlight. Unknown ids and ids currently lifted into an
1897
+ * active edit / transform session are skipped (the live session's own visuals
1898
+ * supersede the passive outline). Hosts use it for Excalidraw-style
1899
+ * "locked selection" chrome.
1900
+ */
1901
+ setHighlightedGraphics(ids: readonly string[]): void;
1902
+ /**
1903
+ * Project the current {@link highlightedIds} into one outline `LineString`
1904
+ * per graphic and reconcile them onto the highlight layer. Idempotent and
1905
+ * id-keyed via `adapter.updateLayerFeatures`, so a set-replace removes stale
1906
+ * outlines and an unchanged set is a cheap no-op write. Skips ids that are
1907
+ * unknown (not in the last render) or lifted into an active session, and
1908
+ * bails gracefully when the adapter cannot invert pixels to coordinates
1909
+ * (`getCoordinateFromPixel` is optional in the MapAdapter ABI).
1910
+ */
1911
+ private renderHighlights;
1912
+ /**
1913
+ * Fit the passive-highlight transform box for one graphic: the point-symbol
1914
+ * footprint for symbols, the snug text footprint for Text graphics, and the
1915
+ * projected-geometry bounding box (at the current bearing) for every other
1916
+ * control measure. Returns `null` when nothing projects this frame.
1917
+ */
1918
+ private highlightBoxForGraphic;
1732
1919
  /**
1733
1920
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
1734
1921
  * pre-rejected promise when called re-entrantly from inside another
@@ -1892,7 +2079,11 @@ declare class TacticalDraw {
1892
2079
  * then `previewLayer`, then `graphicsLayer` — against each layer's current
1893
2080
  * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
1894
2081
  * excluded: it is non-interactive draw-guide scaffolding, never a
1895
- * legitimate "owned" click target.
2082
+ * legitimate "owned" click target. Within each layer, candidates follow
2083
+ * the shared visible-intent ranking (direct marks, then filled interiors,
2084
+ * then inferred outline-only reach, distance within direct marks, then
2085
+ * visual topmost order) — this explicit-affordance layer order is the
2086
+ * tier above all of it.
1896
2087
  *
1897
2088
  * `preview` and `graphics` each get a label text-extent fallback after the
1898
2089
  * geometry pass misses on that layer: for every `Point` feature carrying a
@@ -1903,7 +2094,7 @@ declare class TacticalDraw {
1903
2094
  * This matters most for the active preview, since a click can land
1904
2095
  * anywhere across a rotated/offset label during an edit.
1905
2096
  *
1906
- * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
2097
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
1907
2098
  * pass the host's raw click event as `originalEvent` to get the same
1908
2099
  * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
1909
2100
  * pass an explicit `tolerance` to override it outright.
@@ -1960,6 +2151,7 @@ declare class TacticalDraw {
1960
2151
  /** @internal — exposed for tests and future controllers. */
1961
2152
  get layerIds(): {
1962
2153
  graphics: LayerId;
2154
+ highlight: LayerId;
1963
2155
  preview: LayerId;
1964
2156
  guide: LayerId;
1965
2157
  handles: LayerId;
@@ -1971,6 +2163,7 @@ declare class TacticalDraw {
1971
2163
  */
1972
2164
  private applyGuideStyle;
1973
2165
  private baseGuideStyle;
2166
+ private baseHighlightStyle;
1974
2167
  /**
1975
2168
  * Resolve handle slots (plus `boxOutline`) across built-in → façade →
1976
2169
  * per-call with per-slot, per-property shallow merge. Explicit `undefined`
@@ -2063,4 +2256,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
2063
2256
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
2064
2257
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
2065
2258
  //#endregion
2066
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, 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 PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextSizeProps, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, resolveTextSizePixels, tryControlMeasureIdFromFeature };
2259
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LabelExtent, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type MergedPickFeature, type NativeEventEntry, type PickCandidate, type PickCandidateHit, type PickCandidateHitKind, type PickCandidateInput, type PickEvaluationContext, type PickEvent, type PickHandler, type PickOptions, type PickPointerType, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type ResolvedPickPresentation, type RotatedScreenBounds, type ScreenBounds, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextSizeProps, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextSizePixels, tryControlMeasureIdFromFeature };
package/dist/index.mjs CHANGED
@@ -218,26 +218,17 @@ var BaseMapAdapter = class {
218
218
  return this.featureCache.get(layerId) ?? [];
219
219
  }
220
220
  };
221
- function pickHitTolerance(originalEvent) {
222
- if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
223
- if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return 12;
224
- return 4;
221
+ function pickPointerType(originalEvent) {
222
+ if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
223
+ if (originalEvent.pointerType === "touch") return "touch";
224
+ return originalEvent.pointerType === "pen" ? "pen" : "mouse";
225
+ }
226
+ if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return "touch";
227
+ if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickPointerType(originalEvent.originalEvent);
228
+ return "mouse";
225
229
  }
226
- /**
227
- * Resolve a pixel hit-tolerance for a `MapEvent.originalEvent` delivered by the
228
- * `MapAdapter.on(...)` pipeline. Unlike {@link PickEvent}, that pipeline forwards
229
- * the *engine wrapper* event (OpenLayers `MapBrowserEvent`, MapLibre
230
- * `MapMouseEvent`, Leaflet `LeafletMouseEvent`), each of which carries the raw
231
- * DOM event on a `.originalEvent` property. {@link pickHitTolerance} only
232
- * recognizes a real `PointerEvent` / `TouchEvent`, so unwrap one level when the
233
- * wrapper itself is not recognized as touch. Falls back to the mouse tolerance
234
- * when the input type cannot be determined.
235
- */
236
- function mapEventHitTolerance(originalEvent) {
237
- const direct = pickHitTolerance(originalEvent);
238
- if (direct === 12) return direct;
239
- if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickHitTolerance(originalEvent.originalEvent);
240
- return direct;
230
+ function pickHitTolerance(originalEvent) {
231
+ return pickPointerType(originalEvent) === "touch" ? 12 : 4;
241
232
  }
242
233
  function tryControlMeasureIdFromFeature(feature) {
243
234
  const graphicId = feature.properties?.graphicId;
@@ -268,6 +259,392 @@ function bindAbortable(signal, attach) {
268
259
  return dispose;
269
260
  }
270
261
  //#endregion
262
+ //#region src/pick-candidates.ts
263
+ /**
264
+ * The `visualOrder` for the feature at `index` of a render-ordered
265
+ * (bottom-first) list of `total` features: reversing the index makes the
266
+ * topmost-rendered feature the lowest, and therefore winning, order.
267
+ */
268
+ function renderOrderToVisualOrder(index, total) {
269
+ return total - 1 - index;
270
+ }
271
+ /**
272
+ * Preserve native visual ordering/hit evidence while keeping cached source
273
+ * geometry authoritative. Native polygon geometry may be tile-clipped and
274
+ * therefore contain artificial edges that are not part of the graphic.
275
+ *
276
+ * Normalizes to the shared `visualOrder` contract (lower = visually
277
+ * topmost): native hits already arrive topmost-first from the engines and
278
+ * keep that order at the front. Cached-only features (not among the native
279
+ * hits) are appended after them, in reverse cache order — cache arrays are
280
+ * render order (bottom-first), so reversing yields topmost-first — with
281
+ * `visualOrder` numbering continuing on from the native section.
282
+ */
283
+ function mergeNativeAndCachedPickFeatures(nativeFeatures, cachedFeatures) {
284
+ const cachedById = /* @__PURE__ */ new Map();
285
+ for (const feature of cachedFeatures) if (feature.id != null) cachedById.set(feature.id, feature);
286
+ const merged = [];
287
+ const seenIds = /* @__PURE__ */ new Set();
288
+ for (const nativeFeature of nativeFeatures) {
289
+ if (nativeFeature.id != null && seenIds.has(nativeFeature.id)) continue;
290
+ const feature = nativeFeature.id == null ? nativeFeature : cachedById.get(nativeFeature.id) ?? nativeFeature;
291
+ merged.push({
292
+ feature,
293
+ visualOrder: merged.length,
294
+ nativeHit: true
295
+ });
296
+ if (nativeFeature.id != null) seenIds.add(nativeFeature.id);
297
+ }
298
+ for (let i = cachedFeatures.length - 1; i >= 0; i--) {
299
+ const feature = cachedFeatures[i];
300
+ if (feature.id != null && seenIds.has(feature.id)) continue;
301
+ merged.push({
302
+ feature,
303
+ visualOrder: merged.length,
304
+ nativeHit: false
305
+ });
306
+ if (feature.id != null) seenIds.add(feature.id);
307
+ }
308
+ return merged;
309
+ }
310
+ /**
311
+ * Evaluate every relevant rendered part in screen space, then return the
312
+ * primary candidate under the visible-intent ranking policy: direct visible
313
+ * marks (strokes, points, icons, labels) outrank visibly filled interiors,
314
+ * which outrank inferred outline-only reach; competing direct marks rank by
315
+ * screen-space distance; visual topmost order is only the final tie-break.
316
+ * See `rankPickCandidates` for the full ordered list — that ordering stays
317
+ * internal adapter-author plumbing; the public pick event exposes only this
318
+ * primary candidate.
319
+ */
320
+ function evaluatePickCandidates(inputs, context) {
321
+ let best = null;
322
+ for (const input of inputs) {
323
+ const candidate = buildCandidate(input, context);
324
+ if (candidate && (best === null || compareCandidates(candidate, best) < 0)) best = candidate;
325
+ }
326
+ return best;
327
+ }
328
+ function buildCandidate(input, context) {
329
+ const presentation = input.presentation ?? resolvePickPresentation(input.feature, input.layerStyle);
330
+ const evaluated = evaluateInput(input, presentation, context);
331
+ if (!evaluated) return null;
332
+ return {
333
+ feature: input.feature,
334
+ graphicId: tryControlMeasureIdFromFeature(input.feature),
335
+ renderedPartId: typeof input.feature.id === "string" || typeof input.feature.id === "number" ? input.feature.id : null,
336
+ hit: evaluated.hit,
337
+ viewport: context.viewport,
338
+ presentation,
339
+ visualOrder: input.visualOrder,
340
+ ...evaluated.iconFootprint ? { iconFootprint: evaluated.iconFootprint } : {},
341
+ ...input.labelExtent ? { labelExtent: input.labelExtent } : {}
342
+ };
343
+ }
344
+ function compareCandidates(a, b) {
345
+ const rankA = selectionSurfaceRank(a);
346
+ const rankDifference = rankA - selectionSurfaceRank(b);
347
+ if (rankDifference !== 0) return rankDifference;
348
+ if (rankA === 0 && a.hit.distance !== null && b.hit.distance !== null) {
349
+ const distanceDifference = a.hit.distance - b.hit.distance;
350
+ if (distanceDifference !== 0) return distanceDifference;
351
+ }
352
+ return a.visualOrder - b.visualOrder;
353
+ }
354
+ function selectionSurfaceRank(candidate) {
355
+ if (candidate.hit.kind !== "polygon-interior") return 0;
356
+ return (candidate.presentation.fill?.opacity ?? 0) > 0 ? 1 : 2;
357
+ }
358
+ function resolvePickPresentation(feature, layerStyle) {
359
+ const featureStyle = asRecord(feature.properties?.style);
360
+ const strokeColor = stringValue(featureStyle?.strokeColor) ?? layerStyle?.strokeColor;
361
+ const strokeWidth = numberValue(featureStyle?.strokeWidth) ?? layerStyle?.strokeWidth;
362
+ const strokeDash = numberArray(featureStyle?.strokeDash) ?? layerStyle?.strokeDash;
363
+ const fillColor = stringValue(featureStyle?.fillColor) ?? layerStyle?.fillColor;
364
+ const fillPattern = stringValue(featureStyle?.fillPattern);
365
+ const pointFillColor = stringValue(featureStyle?.pointFillColor) ?? stringValue(featureStyle?.fillColor) ?? layerStyle?.pointFillColor ?? layerStyle?.fillColor;
366
+ const pointRadius = numberValue(featureStyle?.pointRadius) ?? layerStyle?.pointRadius;
367
+ const presentation = {};
368
+ if (strokeColor !== void 0 || strokeWidth !== void 0 || strokeDash !== void 0) presentation.stroke = {
369
+ ...strokeColor !== void 0 ? { color: strokeColor } : {},
370
+ ...strokeWidth !== void 0 ? { width: strokeWidth } : {},
371
+ ...strokeDash !== void 0 ? { dash: strokeDash } : {}
372
+ };
373
+ if (fillColor !== void 0 || fillPattern !== void 0) presentation.fill = {
374
+ ...fillColor !== void 0 ? { color: fillColor } : {},
375
+ ...fillPattern !== void 0 ? { pattern: fillPattern } : {},
376
+ opacity: colorOpacity(fillColor)
377
+ };
378
+ if (feature.geometry.type === "Point" && (pointFillColor !== void 0 || pointRadius !== void 0)) presentation.point = {
379
+ ...pointFillColor !== void 0 ? { fillColor: pointFillColor } : {},
380
+ ...pointRadius !== void 0 ? { radius: pointRadius } : {}
381
+ };
382
+ if (layerStyle?.text !== void 0) presentation.text = { ...layerStyle.text };
383
+ return presentation;
384
+ }
385
+ function evaluateInput(input, presentation, context) {
386
+ if (input.labelExtent && pointInExtent(context.pixel, input.labelExtent)) return { hit: {
387
+ kind: "label",
388
+ distance: 0
389
+ } };
390
+ if (input.icon) return evaluateIcon(input.feature, input.icon, context);
391
+ const geometryHit = evaluateGeometry(input.feature, presentation, context);
392
+ if (geometryHit) return { hit: geometryHit };
393
+ if (input.feature.geometry.type === "Polygon" || input.feature.geometry.type === "MultiPolygon") return null;
394
+ return input.nativeHit ? { hit: {
395
+ kind: inferNativeHitKind(input.feature),
396
+ distance: null
397
+ } } : null;
398
+ }
399
+ function inferNativeHitKind(feature) {
400
+ if (typeof feature.properties?.iconId === "string") return "icon";
401
+ if (typeof feature.properties?.text === "string") return "label";
402
+ if (feature.geometry.type === "Point") return "point";
403
+ if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") return "line";
404
+ return "native";
405
+ }
406
+ function evaluateGeometry(feature, presentation, context) {
407
+ const geometry = feature.geometry;
408
+ const { pixel, tolerance, toPixel } = context;
409
+ if (geometry.type === "Point") {
410
+ const projected = toPixel(geometry.coordinates);
411
+ if (!projected) return null;
412
+ const distance = pointDistance(pixel, projected);
413
+ return distance <= tolerance ? {
414
+ kind: "point",
415
+ distance
416
+ } : null;
417
+ }
418
+ if (geometry.type === "LineString") {
419
+ const distance = distanceToLine(pixel, geometry.coordinates, toPixel);
420
+ return distance !== null && distance <= tolerance ? {
421
+ kind: "line",
422
+ distance
423
+ } : null;
424
+ }
425
+ if (geometry.type === "MultiLineString") {
426
+ const distance = minimum(geometry.coordinates.map((line) => distanceToLine(pixel, line, toPixel)));
427
+ return distance !== null && distance <= tolerance ? {
428
+ kind: "line",
429
+ distance
430
+ } : null;
431
+ }
432
+ if (geometry.type === "Polygon") return evaluatePolygon(pixel, geometry.coordinates, presentation, context);
433
+ if (geometry.type === "MultiPolygon") return bestPolygonHit(geometry.coordinates.map((polygon) => evaluatePolygon(pixel, polygon, presentation, context)).filter((hit) => hit !== null));
434
+ return null;
435
+ }
436
+ function evaluatePolygon(pixel, rings, presentation, context) {
437
+ const { tolerance, toPixel, viewport } = context;
438
+ const projectedRings = rings.map((ring) => ring.map(toPixel));
439
+ const distance = hasVisibleBoundary(presentation) ? minimum(projectedRings.map((points) => minVisibleSegmentDistance(pixel, points, viewport))) : null;
440
+ if (distance !== null && distance <= tolerance) return {
441
+ kind: "polygon-boundary",
442
+ distance
443
+ };
444
+ if (!(projectedRings.length > 0 && pointInProjectedRing(pixel, projectedRings[0]) && !projectedRings.slice(1).some((points) => pointInProjectedRing(pixel, points)))) return null;
445
+ const visiblyFilled = (presentation.fill?.opacity ?? 0) > 0;
446
+ const inwardReach = context.pointerType === "touch" ? 36 : 24;
447
+ if (visiblyFilled || distance !== null && distance <= inwardReach) return {
448
+ kind: "polygon-interior",
449
+ distance
450
+ };
451
+ return null;
452
+ }
453
+ function bestPolygonHit(hits) {
454
+ if (hits.length === 0) return null;
455
+ const boundary = hits.filter((hit) => hit.kind === "polygon-boundary");
456
+ if (boundary.length > 0) return boundary.reduce((best, hit) => (hit.distance ?? Infinity) < (best.distance ?? Infinity) ? hit : best);
457
+ return hits[0];
458
+ }
459
+ function evaluateIcon(feature, resource, context) {
460
+ if (feature.geometry.type !== "Point") return null;
461
+ const anchor = context.toPixel(feature.geometry.coordinates);
462
+ if (!anchor) return null;
463
+ const rawScale = feature.properties?.iconScale;
464
+ const scale = typeof rawScale === "number" ? rawScale : 1;
465
+ if (!Number.isFinite(scale) || scale <= 0) return null;
466
+ const rawRotation = feature.properties?.iconRotation;
467
+ const angle = (typeof rawRotation === "number" && Number.isFinite(rawRotation) ? rawRotation : 0) - (context.bearing ?? 0);
468
+ const cos = Math.cos(angle);
469
+ const sin = Math.sin(angle);
470
+ const minX = -resource.anchor[0] * scale;
471
+ const maxX = (resource.width - resource.anchor[0]) * scale;
472
+ const minY = -resource.anchor[1] * scale;
473
+ const maxY = (resource.height - resource.anchor[1]) * scale;
474
+ const dx = context.pixel[0] - anchor[0];
475
+ const dy = context.pixel[1] - anchor[1];
476
+ const localX = dx * cos + dy * sin;
477
+ const localY = -dx * sin + dy * cos;
478
+ const outsideX = Math.max(minX - localX, 0, localX - maxX);
479
+ const outsideY = Math.max(minY - localY, 0, localY - maxY);
480
+ if (outsideX > context.tolerance || outsideY > context.tolerance) return null;
481
+ const coreRect = resource.coreRect ?? {
482
+ x: 0,
483
+ y: 0,
484
+ width: resource.width,
485
+ height: resource.height
486
+ };
487
+ const coreMinX = (coreRect.x - resource.anchor[0]) * scale;
488
+ const coreMaxX = (coreRect.x + coreRect.width - resource.anchor[0]) * scale;
489
+ const coreMinY = (coreRect.y - resource.anchor[1]) * scale;
490
+ const coreMaxY = (coreRect.y + coreRect.height - resource.anchor[1]) * scale;
491
+ const coreOutsideX = Math.max(coreMinX - localX, 0, localX - coreMaxX);
492
+ const coreOutsideY = Math.max(coreMinY - localY, 0, localY - coreMaxY);
493
+ const distance = Math.hypot(coreOutsideX, coreOutsideY);
494
+ const corner = (x, y) => [anchor[0] + x * cos - y * sin, anchor[1] + x * sin + y * cos];
495
+ return {
496
+ hit: {
497
+ kind: "icon",
498
+ distance
499
+ },
500
+ iconFootprint: [
501
+ corner(minX, minY),
502
+ corner(maxX, minY),
503
+ corner(maxX, maxY),
504
+ corner(minX, maxY)
505
+ ]
506
+ };
507
+ }
508
+ function distanceToLine(pixel, coordinates, toPixel) {
509
+ return minSegmentDistance(pixel, coordinates.map(toPixel));
510
+ }
511
+ /** Nearest distance from `pixel` to any segment of a pre-projected polyline. */
512
+ function minSegmentDistance(pixel, points) {
513
+ let closest = null;
514
+ for (let i = 0; i < points.length - 1; i++) {
515
+ const a = points[i];
516
+ const b = points[i + 1];
517
+ if (!a || !b) continue;
518
+ const distance = distanceToSegment(pixel, a, b);
519
+ if (distance !== null && (closest === null || distance < closest)) closest = distance;
520
+ }
521
+ return closest;
522
+ }
523
+ /** As `minSegmentDistance`, but each segment is first clipped to `viewport`. */
524
+ function minVisibleSegmentDistance(pixel, points, viewport) {
525
+ let closest = null;
526
+ for (let i = 0; i < points.length - 1; i++) {
527
+ const a = points[i];
528
+ const b = points[i + 1];
529
+ if (!a || !b) continue;
530
+ const visible = clipSegmentToBounds(a, b, viewport);
531
+ if (!visible) continue;
532
+ const [visibleA, visibleB] = visible;
533
+ const distance = visibleA[0] === visibleB[0] && visibleA[1] === visibleB[1] ? pointDistance(pixel, visibleA) : distanceToSegment(pixel, visibleA, visibleB);
534
+ if (distance !== null && (closest === null || distance < closest)) closest = distance;
535
+ }
536
+ return closest;
537
+ }
538
+ function clipSegmentToBounds(a, b, bounds) {
539
+ const dx = b[0] - a[0];
540
+ const dy = b[1] - a[1];
541
+ let start = 0;
542
+ let end = 1;
543
+ const edges = [
544
+ [-dx, a[0] - bounds.minX],
545
+ [dx, bounds.maxX - a[0]],
546
+ [-dy, a[1] - bounds.minY],
547
+ [dy, bounds.maxY - a[1]]
548
+ ];
549
+ for (const [direction, offset] of edges) {
550
+ if (direction === 0) {
551
+ if (offset < 0) return null;
552
+ continue;
553
+ }
554
+ const ratio = offset / direction;
555
+ if (direction < 0) start = Math.max(start, ratio);
556
+ else end = Math.min(end, ratio);
557
+ if (start > end) return null;
558
+ }
559
+ return [[a[0] + start * dx, a[1] + start * dy], [a[0] + end * dx, a[1] + end * dy]];
560
+ }
561
+ function distanceToSegment(pixel, a, b) {
562
+ const dx = b[0] - a[0];
563
+ const dy = b[1] - a[1];
564
+ const lengthSq = dx * dx + dy * dy;
565
+ if (lengthSq === 0) return null;
566
+ const unclamped = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
567
+ const t = Math.max(0, Math.min(1, unclamped));
568
+ return Math.hypot(pixel[0] - (a[0] + t * dx), pixel[1] - (a[1] + t * dy));
569
+ }
570
+ /**
571
+ * Even-odd point-in-ring test over a pre-projected ring. Any vertex that
572
+ * failed to project makes the whole ring inconclusive, matching the prior
573
+ * "project every vertex or bail" contract.
574
+ */
575
+ function pointInProjectedRing(pixel, points) {
576
+ let inside = false;
577
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
578
+ const pi = points[i];
579
+ const pj = points[j];
580
+ if (!pi || !pj) return false;
581
+ const [xi, yi] = pi;
582
+ const [xj, yj] = pj;
583
+ if (yi > pixel[1] !== yj > pixel[1] && pixel[0] < (xj - xi) * (pixel[1] - yi) / (yj - yi) + xi) inside = !inside;
584
+ }
585
+ return inside;
586
+ }
587
+ function pointInExtent(pixel, extent) {
588
+ if ("minX" in extent) return pixel[0] >= extent.minX && pixel[0] <= extent.maxX && pixel[1] >= extent.minY && pixel[1] <= extent.maxY;
589
+ const dx = pixel[0] - extent.center[0];
590
+ const dy = pixel[1] - extent.center[1];
591
+ const cos = Math.cos(-extent.angle);
592
+ const sin = Math.sin(-extent.angle);
593
+ const localX = dx * cos - dy * sin;
594
+ const localY = dx * sin + dy * cos;
595
+ return Math.abs(localX) <= extent.halfWidth && Math.abs(localY) <= extent.halfHeight;
596
+ }
597
+ function pointDistance(a, b) {
598
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
599
+ }
600
+ function minimum(values) {
601
+ let result = null;
602
+ for (const value of values) if (value !== null && (result === null || value < result)) result = value;
603
+ return result;
604
+ }
605
+ function colorOpacity(color) {
606
+ if (color === void 0) return 0;
607
+ const normalized = color.trim().toLowerCase();
608
+ if (normalized === "transparent") return 0;
609
+ if (/^#[0-9a-f]{4}$/i.test(normalized)) return parseInt(normalized[4], 16) / 15;
610
+ if (/^#[0-9a-f]{8}$/i.test(normalized)) return parseInt(normalized.slice(7, 9), 16) / 255;
611
+ const functional = normalized.match(/^[a-z]+\((.*)\)$/);
612
+ if (functional) {
613
+ const body = functional[1];
614
+ const slashAlpha = body.match(/\/\s*([+-]?(?:\d*\.)?\d+)(%)?\s*$/);
615
+ if (slashAlpha) return normalizedAlpha(slashAlpha[1], slashAlpha[2] === "%");
616
+ const commaParts = body.split(",").map((part) => part.trim());
617
+ if (commaParts.length === 4) {
618
+ const alpha = commaParts[3].match(/^([+-]?(?:\d*\.)?\d+)(%)?$/);
619
+ if (alpha) return normalizedAlpha(alpha[1], alpha[2] === "%");
620
+ }
621
+ }
622
+ return 1;
623
+ }
624
+ function normalizedAlpha(value, percentage) {
625
+ const alpha = Number(value);
626
+ if (!Number.isFinite(alpha)) return 1;
627
+ return Math.max(0, Math.min(1, percentage ? alpha / 100 : alpha));
628
+ }
629
+ function hasVisibleBoundary(presentation) {
630
+ const stroke = presentation.stroke;
631
+ if (!stroke || stroke.color === void 0) return false;
632
+ if (stroke.width !== void 0 && stroke.width <= 0) return false;
633
+ return colorOpacity(stroke.color) > 0;
634
+ }
635
+ function asRecord(value) {
636
+ return value !== null && typeof value === "object" ? value : void 0;
637
+ }
638
+ function stringValue(value) {
639
+ return typeof value === "string" ? value : void 0;
640
+ }
641
+ function numberValue(value) {
642
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
643
+ }
644
+ function numberArray(value) {
645
+ return Array.isArray(value) && value.every((item) => typeof item === "number") ? [...value] : void 0;
646
+ }
647
+ //#endregion
271
648
  //#region src/feature-style.ts
272
649
  /**
273
650
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -334,41 +711,6 @@ function clampedSegmentParameter(pixel, a, b) {
334
711
  const t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
335
712
  return Math.max(0, Math.min(1, t));
336
713
  }
337
- /** Distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
338
- function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
339
- const a = toPixel(p1);
340
- const b = toPixel(p2);
341
- if (!a || !b) return null;
342
- const t = clampedSegmentParameter(pixel, a, b);
343
- if (t === null) return null;
344
- const ddx = pixel[0] - (a[0] + t * (b[0] - a[0]));
345
- const ddy = pixel[1] - (a[1] + t * (b[1] - a[1]));
346
- return Math.sqrt(ddx * ddx + ddy * ddy);
347
- }
348
- /** Even-odd point-in-ring test in pixel space. */
349
- function pixelPointInRing(pixel, ring, toPixel) {
350
- const projected = [];
351
- for (const c of ring) {
352
- const p = toPixel(c);
353
- if (!p) return false;
354
- projected.push(p);
355
- }
356
- const [px, py] = pixel;
357
- let inside = false;
358
- for (let i = 0, j = projected.length - 1; i < projected.length; j = i++) {
359
- const xi = projected[i][0], yi = projected[i][1];
360
- const xj = projected[j][0], yj = projected[j][1];
361
- if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
362
- }
363
- return inside;
364
- }
365
- function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
366
- for (let i = 0; i < ring.length - 1; i++) {
367
- const d = pixelDistanceToSegment(pixel, ring[i], ring[i + 1], toPixel);
368
- if (d !== null && d <= hitTolerance) return true;
369
- }
370
- return false;
371
- }
372
714
  /**
373
715
  * Test whether `pixel` hits any feature in `features`, in pixel space.
374
716
  *
@@ -381,23 +723,28 @@ function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
381
723
  * Returns the first matching feature in iteration order, or null.
382
724
  */
383
725
  function hitTestFeature(pixel, features, hitTolerance, toPixel) {
384
- const [px, py] = pixel;
385
- for (const f of features) {
386
- if (!f) continue;
387
- const geom = f.geometry;
388
- let hit = false;
389
- if (geom.type === "Point") {
390
- const p = toPixel(geom.coordinates);
391
- if (p) {
392
- const dx = p[0] - px;
393
- const dy = p[1] - py;
394
- hit = Math.sqrt(dx * dx + dy * dy) <= hitTolerance;
395
- }
396
- } else if (geom.type === "LineString") hit = isNearAnySegment(pixel, geom.coordinates, hitTolerance, toPixel);
397
- else if (geom.type === "MultiLineString") hit = geom.coordinates.some((line) => isNearAnySegment(pixel, line, hitTolerance, toPixel));
398
- else if (geom.type === "Polygon") hit = geom.coordinates.some((ring) => pixelPointInRing(pixel, ring, toPixel)) || geom.coordinates.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel));
399
- else if (geom.type === "MultiPolygon") hit = geom.coordinates.some((poly) => poly.some((ring) => pixelPointInRing(pixel, ring, toPixel))) || geom.coordinates.some((poly) => poly.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel)));
400
- if (hit) return f;
726
+ for (const feature of features) {
727
+ if (!feature) continue;
728
+ const candidate = evaluatePickCandidates([{
729
+ feature,
730
+ visualOrder: 0,
731
+ ...feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon" ? { presentation: {
732
+ stroke: {
733
+ color: "#000",
734
+ width: 1
735
+ },
736
+ fill: {
737
+ color: "#000",
738
+ opacity: 1
739
+ }
740
+ } } : {}
741
+ }], {
742
+ pixel,
743
+ tolerance: hitTolerance,
744
+ toPixel,
745
+ viewport: UNBOUNDED_VIEWPORT
746
+ });
747
+ if (candidate) return candidate.feature;
401
748
  }
402
749
  return null;
403
750
  }
@@ -408,26 +755,24 @@ function hitTestFeature(pixel, features, hitTolerance, toPixel) {
408
755
  * unscaled SVG viewport. A positive tolerance pads all four sides.
409
756
  */
410
757
  function hitTestIconFeature(pixel, feature, resource, hitTolerance, toPixel, bearing = 0) {
411
- if (feature.geometry.type !== "Point") return false;
412
- const anchor = toPixel(feature.geometry.coordinates);
413
- if (!anchor) return false;
414
- const rawScale = feature.properties?.iconScale;
415
- const scale = typeof rawScale === "number" ? rawScale : 1;
416
- if (!Number.isFinite(scale) || scale <= 0) return false;
417
- const rawRotation = feature.properties?.iconRotation;
418
- const angle = (typeof rawRotation === "number" && Number.isFinite(rawRotation) ? rawRotation : 0) - bearing;
419
- const cos = Math.cos(angle);
420
- const sin = Math.sin(angle);
421
- const dx = pixel[0] - anchor[0];
422
- const dy = pixel[1] - anchor[1];
423
- const localX = dx * cos + dy * sin;
424
- const localY = -dx * sin + dy * cos;
425
- const minX = -resource.anchor[0] * scale - hitTolerance;
426
- const maxX = (resource.width - resource.anchor[0]) * scale + hitTolerance;
427
- const minY = -resource.anchor[1] * scale - hitTolerance;
428
- const maxY = (resource.height - resource.anchor[1]) * scale + hitTolerance;
429
- return localX >= minX && localX <= maxX && localY >= minY && localY <= maxY;
758
+ return evaluatePickCandidates([{
759
+ feature,
760
+ visualOrder: 0,
761
+ icon: resource
762
+ }], {
763
+ pixel,
764
+ tolerance: hitTolerance,
765
+ toPixel,
766
+ viewport: UNBOUNDED_VIEWPORT,
767
+ bearing
768
+ }) !== null;
430
769
  }
770
+ const UNBOUNDED_VIEWPORT = {
771
+ minX: Number.NEGATIVE_INFINITY,
772
+ minY: Number.NEGATIVE_INFINITY,
773
+ maxX: Number.POSITIVE_INFINITY,
774
+ maxY: Number.POSITIVE_INFINITY
775
+ };
431
776
  //#endregion
432
777
  //#region src/gestures/hit-tolerance.ts
433
778
  const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -1809,7 +2154,7 @@ function runVariableLengthDraw(options) {
1809
2154
  if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
1810
2155
  const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
1811
2156
  if (!finishPixel) continue;
1812
- const tol = mapEventHitTolerance(event.originalEvent);
2157
+ const tol = pickHitTolerance(event.originalEvent);
1813
2158
  const dx = event.pixel[0] - finishPixel[0];
1814
2159
  const dy = event.pixel[1] - finishPixel[1];
1815
2160
  if (dx * dx + dy * dy <= tol * tol) {
@@ -4363,7 +4708,13 @@ function renderPointSymbolForAdapter(adapter, capability, symbol) {
4363
4708
  svg: render.svg,
4364
4709
  width: render.width,
4365
4710
  height: render.height,
4366
- anchor: [render.anchor.x, render.anchor.y]
4711
+ anchor: [render.anchor.x, render.anchor.y],
4712
+ coreRect: {
4713
+ x: render.octagonAnchor.x - render.octagonSize / 2,
4714
+ y: render.octagonAnchor.y - render.octagonSize / 2,
4715
+ width: render.octagonSize,
4716
+ height: render.octagonSize
4717
+ }
4367
4718
  },
4368
4719
  feature: pointSymbolFeature(symbol, render, adapter.getResolution())
4369
4720
  };
@@ -5492,6 +5843,7 @@ function runPointSymbolEdit(opts) {
5492
5843
  var TacticalDraw = class {
5493
5844
  adapter;
5494
5845
  graphicsLayer;
5846
+ highlightLayer;
5495
5847
  previewLayer;
5496
5848
  guideLayer;
5497
5849
  handlesLayer;
@@ -5553,6 +5905,20 @@ var TacticalDraw = class {
5553
5905
  */
5554
5906
  lastRenderedMeasures = [];
5555
5907
  snapGeometryFeatures = [];
5908
+ /**
5909
+ * Ids of graphics currently carrying a passive selection-highlight outline
5910
+ * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
5911
+ * re-projected on every view change and re-evaluated at the end of every
5912
+ * `render()` so outlines track geometry, style, and dropped graphics.
5913
+ */
5914
+ highlightedIds = [];
5915
+ /**
5916
+ * Whether the highlight layer currently holds any outline. Lets
5917
+ * `renderHighlights` skip a redundant empty write on every view change /
5918
+ * render while no highlights are set, but still emit the one clearing write
5919
+ * that removes outlines when the set transitions to empty.
5920
+ */
5921
+ hasHighlightOutlines = false;
5556
5922
  constructor(adapter, options) {
5557
5923
  this.adapter = adapter;
5558
5924
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
@@ -5564,6 +5930,7 @@ var TacticalDraw = class {
5564
5930
  const owned = /* @__PURE__ */ new Set();
5565
5931
  const provided = options?.layers ?? {};
5566
5932
  this.graphicsLayer = resolveSlot(adapter, provided.graphics, owned);
5933
+ this.highlightLayer = resolveSlot(adapter, provided.highlight, owned);
5567
5934
  this.previewLayer = resolveSlot(adapter, provided.preview, owned);
5568
5935
  this.guideLayer = resolveSlot(adapter, provided.guide, owned);
5569
5936
  this.handlesLayer = resolveSlot(adapter, provided.handles, owned);
@@ -5572,6 +5939,7 @@ var TacticalDraw = class {
5572
5939
  this.adapter.setLayerStyle(this.graphicsLayer, graphicsLayerStyle);
5573
5940
  this.adapter.setLayerStyle(this.previewLayer, graphicsLayerStyle);
5574
5941
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
5942
+ this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
5575
5943
  this.adapter.onViewChange(this.handleViewChange);
5576
5944
  }
5577
5945
  /** Replace snapping configuration without recreating the map adapter or façade. */
@@ -5619,6 +5987,7 @@ var TacticalDraw = class {
5619
5987
  */
5620
5988
  handleViewChange = () => {
5621
5989
  if (this.destroyed || !this.hasRendered) return;
5990
+ this.renderHighlights();
5622
5991
  const excluded = this.excludedInteractionIds();
5623
5992
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
5624
5993
  const nativeGroundIconScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
@@ -5716,6 +6085,88 @@ var TacticalDraw = class {
5716
6085
  if (handle.graphicIds.some((id) => !rendered.has(id))) handle.abort("removed");
5717
6086
  });
5718
6087
  }
6088
+ this.renderHighlights();
6089
+ }
6090
+ /**
6091
+ * Replace the passive selection-highlight set with `ids`. Declarative and
6092
+ * idempotent (mirrors {@link render}): a non-interactive outline box is drawn
6093
+ * around each still-rendered graphic on the dedicated highlight layer — no
6094
+ * draw / edit / transform session, no promise, no pick subscription. Passing
6095
+ * `[]` clears every highlight. Unknown ids and ids currently lifted into an
6096
+ * active edit / transform session are skipped (the live session's own visuals
6097
+ * supersede the passive outline). Hosts use it for Excalidraw-style
6098
+ * "locked selection" chrome.
6099
+ */
6100
+ setHighlightedGraphics(ids) {
6101
+ this.assertAlive();
6102
+ this.highlightedIds = ids.slice();
6103
+ this.renderHighlights();
6104
+ }
6105
+ /**
6106
+ * Project the current {@link highlightedIds} into one outline `LineString`
6107
+ * per graphic and reconcile them onto the highlight layer. Idempotent and
6108
+ * id-keyed via `adapter.updateLayerFeatures`, so a set-replace removes stale
6109
+ * outlines and an unchanged set is a cheap no-op write. Skips ids that are
6110
+ * unknown (not in the last render) or lifted into an active session, and
6111
+ * bails gracefully when the adapter cannot invert pixels to coordinates
6112
+ * (`getCoordinateFromPixel` is optional in the MapAdapter ABI).
6113
+ */
6114
+ renderHighlights() {
6115
+ if (this.destroyed) return;
6116
+ if (this.highlightedIds.length === 0 && !this.hasHighlightOutlines) return;
6117
+ const fromPixel = this.adapter.getCoordinateFromPixel?.bind(this.adapter);
6118
+ if (!fromPixel) return;
6119
+ const resolved = this.resolveInteractionStyle(void 0);
6120
+ const style = resolved.highlightOutline;
6121
+ const padding = resolved.boxPadding ?? 0;
6122
+ const excluded = this.excludedInteractionIds();
6123
+ const bearing = this.adapter.getBearing();
6124
+ const features = [];
6125
+ for (const id of this.highlightedIds) {
6126
+ if (excluded.has(id)) continue;
6127
+ const graphic = this.lastRenderedMeasures.find((g) => g.id === id);
6128
+ if (!graphic) continue;
6129
+ const box = this.highlightBoxForGraphic(graphic, bearing, padding);
6130
+ if (!box) continue;
6131
+ let invertFailed = false;
6132
+ const invert = (pixel) => {
6133
+ const coord = fromPixel(pixel);
6134
+ if (!coord) {
6135
+ invertFailed = true;
6136
+ return [pixel[0], pixel[1]];
6137
+ }
6138
+ return coord;
6139
+ };
6140
+ const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
6141
+ if (!invertFailed) features.push(feature);
6142
+ }
6143
+ this.adapter.updateLayerFeatures(this.highlightLayer, features);
6144
+ this.hasHighlightOutlines = features.length > 0;
6145
+ }
6146
+ /**
6147
+ * Fit the passive-highlight transform box for one graphic: the point-symbol
6148
+ * footprint for symbols, the snug text footprint for Text graphics, and the
6149
+ * projected-geometry bounding box (at the current bearing) for every other
6150
+ * control measure. Returns `null` when nothing projects this frame.
6151
+ */
6152
+ highlightBoxForGraphic(graphic, bearing, padding) {
6153
+ if (isPointSymbol(graphic)) {
6154
+ if (!this.pointSymbols) return null;
6155
+ return pointSymbolFootprint(graphic, this.pointSymbols.render(graphic), this.adapter)?.box ?? null;
6156
+ }
6157
+ const render = renderMeasureForAdapter(this.adapter, graphic, { graphicsStyle: this.graphicsStyle });
6158
+ if (graphic.kind === "text") return fitTextFootprintFromFeatures(render.features, (lonLat) => this.adapter.getPixelFromCoordinate(lonLat), {
6159
+ resolution: this.adapter.getResolution(),
6160
+ bearing
6161
+ });
6162
+ const positions = [];
6163
+ for (const feature of render.features) if (feature.geometry) collectFeaturePositions(feature.geometry, positions);
6164
+ const pixels = [];
6165
+ for (const position of positions) {
6166
+ const pixel = this.adapter.getPixelFromCoordinate(position);
6167
+ if (pixel) pixels.push(pixel);
6168
+ }
6169
+ return fitTransformBox(pixels, bearing, padding);
5719
6170
  }
5720
6171
  /**
5721
6172
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
@@ -5770,6 +6221,7 @@ var TacticalDraw = class {
5770
6221
  clearSlot();
5771
6222
  this.clearSnapResult();
5772
6223
  if (extraSettle) extraSettle();
6224
+ this.renderHighlights();
5773
6225
  };
5774
6226
  this.activeInteraction.begin();
5775
6227
  try {
@@ -5781,7 +6233,10 @@ var TacticalDraw = class {
5781
6233
  }
5782
6234
  if (!settledDuringCreate) {
5783
6235
  this.activeInteraction.set(handle);
5784
- if (activate && !handle.settled) deactivate = activate(handle) ?? null;
6236
+ if (activate && !handle.settled) {
6237
+ deactivate = activate(handle) ?? null;
6238
+ this.renderHighlights();
6239
+ }
5785
6240
  }
5786
6241
  handle.promise.then(clearSlot, clearSlot);
5787
6242
  return handle.promise;
@@ -6116,7 +6571,11 @@ var TacticalDraw = class {
6116
6571
  * then `previewLayer`, then `graphicsLayer` — against each layer's current
6117
6572
  * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
6118
6573
  * excluded: it is non-interactive draw-guide scaffolding, never a
6119
- * legitimate "owned" click target.
6574
+ * legitimate "owned" click target. Within each layer, candidates follow
6575
+ * the shared visible-intent ranking (direct marks, then filled interiors,
6576
+ * then inferred outline-only reach, distance within direct marks, then
6577
+ * visual topmost order) — this explicit-affordance layer order is the
6578
+ * tier above all of it.
6120
6579
  *
6121
6580
  * `preview` and `graphics` each get a label text-extent fallback after the
6122
6581
  * geometry pass misses on that layer: for every `Point` feature carrying a
@@ -6127,7 +6586,7 @@ var TacticalDraw = class {
6127
6586
  * This matters most for the active preview, since a click can land
6128
6587
  * anywhere across a rotated/offset label during an edit.
6129
6588
  *
6130
- * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
6589
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
6131
6590
  * pass the host's raw click event as `originalEvent` to get the same
6132
6591
  * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
6133
6592
  * pass an explicit `tolerance` to override it outright.
@@ -6143,7 +6602,7 @@ var TacticalDraw = class {
6143
6602
  */
6144
6603
  ownsInteractionAt(pixel, options) {
6145
6604
  this.assertAlive();
6146
- const tolerance = options?.tolerance ?? mapEventHitTolerance(options?.originalEvent);
6605
+ const tolerance = options?.tolerance ?? pickHitTolerance(options?.originalEvent);
6147
6606
  const toPixel = (lonLat) => this.adapter.getPixelFromCoordinate(lonLat);
6148
6607
  const layers = [
6149
6608
  {
@@ -6162,20 +6621,48 @@ var TacticalDraw = class {
6162
6621
  labelFallback: true
6163
6622
  }
6164
6623
  ];
6624
+ const size = this.adapter.getViewportSize();
6625
+ const context = {
6626
+ pixel,
6627
+ pointerType: pickPointerType(options?.originalEvent),
6628
+ tolerance,
6629
+ toPixel,
6630
+ viewport: {
6631
+ minX: 0,
6632
+ minY: 0,
6633
+ maxX: size.width,
6634
+ maxY: size.height
6635
+ },
6636
+ bearing: this.adapter.getBearing()
6637
+ };
6165
6638
  for (const { layer, layerId, labelFallback } of layers) {
6166
6639
  const features = this.adapter.getLayerFeatures(layerId);
6167
- const hit = hitTestFeature(pixel, features, tolerance, toPixel);
6168
- if (hit) return this.toInteractionHit(layer, hit);
6169
- if (labelFallback) for (const feature of features) {
6170
- if (feature.geometry?.type !== "Point") continue;
6171
- if (typeof feature.properties?.text !== "string") continue;
6172
- const anchorPixel = toPixel(feature.geometry.coordinates);
6173
- if (!anchorPixel) continue;
6174
- const box = fitLabelBoxFromFeature(feature, anchorPixel, {
6175
- resolution: this.adapter.getResolution(),
6176
- bearing: this.adapter.getBearing()
6177
- });
6178
- if (box && pointInBox(box, pixel)) return this.toInteractionHit(layer, feature);
6640
+ const geometryCandidate = evaluatePickCandidates(features.map((feature, index) => ({
6641
+ feature,
6642
+ visualOrder: renderOrderToVisualOrder(index, features.length)
6643
+ })), context);
6644
+ if (geometryCandidate) return this.toInteractionHit(layer, geometryCandidate.feature);
6645
+ if (labelFallback) {
6646
+ const labelCandidates = [];
6647
+ for (const feature of features) {
6648
+ if (feature.geometry?.type !== "Point") continue;
6649
+ if (typeof feature.properties?.text !== "string") continue;
6650
+ const anchorPixel = toPixel(feature.geometry.coordinates);
6651
+ if (!anchorPixel) continue;
6652
+ const box = fitLabelBoxFromFeature(feature, anchorPixel, {
6653
+ resolution: this.adapter.getResolution(),
6654
+ bearing: this.adapter.getBearing()
6655
+ });
6656
+ if (box) labelCandidates.push({
6657
+ feature,
6658
+ labelExtent: box
6659
+ });
6660
+ }
6661
+ const labelCandidate = evaluatePickCandidates(labelCandidates.map((candidate, index) => ({
6662
+ ...candidate,
6663
+ visualOrder: renderOrderToVisualOrder(index, labelCandidates.length)
6664
+ })), context);
6665
+ if (labelCandidate) return this.toInteractionHit(layer, labelCandidate.feature);
6179
6666
  }
6180
6667
  }
6181
6668
  return null;
@@ -6265,6 +6752,7 @@ var TacticalDraw = class {
6265
6752
  get layerIds() {
6266
6753
  return {
6267
6754
  graphics: this.graphicsLayer,
6755
+ highlight: this.highlightLayer,
6268
6756
  preview: this.previewLayer,
6269
6757
  guide: this.guideLayer,
6270
6758
  handles: this.handlesLayer
@@ -6287,6 +6775,9 @@ var TacticalDraw = class {
6287
6775
  baseGuideStyle() {
6288
6776
  return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
6289
6777
  }
6778
+ baseHighlightStyle() {
6779
+ return mergeSlot(BUILT_IN_INTERACTION_STYLE.highlightOutline, this.defaultInteractionStyle?.highlightOutline);
6780
+ }
6290
6781
  /**
6291
6782
  * Resolve handle slots (plus `boxOutline`) across built-in → façade →
6292
6783
  * per-call with per-slot, per-property shallow merge. Explicit `undefined`
@@ -6314,6 +6805,8 @@ var TacticalDraw = class {
6314
6805
  }
6315
6806
  const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
6316
6807
  if (boxOutline) out.boxOutline = boxOutline;
6808
+ const highlightOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.highlightOutline, this.defaultInteractionStyle?.highlightOutline), perCall?.highlightOutline);
6809
+ if (highlightOutline) out.highlightOutline = highlightOutline;
6317
6810
  const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
6318
6811
  if (typeof boxPadding === "number") out.boxPadding = boxPadding;
6319
6812
  return out;
@@ -6354,6 +6847,11 @@ const BUILT_IN_INTERACTION_STYLE = {
6354
6847
  strokeColor: "#4f7cff",
6355
6848
  strokeWidth: 1
6356
6849
  },
6850
+ highlightOutline: {
6851
+ strokeColor: "#8ea3c9",
6852
+ strokeWidth: 1.5,
6853
+ strokeDash: [4, 4]
6854
+ },
6357
6855
  vertexHandle: {
6358
6856
  pointRadius: 6,
6359
6857
  fillColor: "#ffffff",
@@ -6593,4 +7091,4 @@ function nearestWorldCopyPosition(position, refLng) {
6593
7091
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
6594
7092
  }
6595
7093
  //#endregion
6596
- export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, resolveTextSizePixels, tryControlMeasureIdFromFeature };
7094
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextSizePixels, tryControlMeasureIdFromFeature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.31",
3
+ "version": "0.2.0-alpha.33",
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",