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

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
@@ -1892,7 +2021,11 @@ declare class TacticalDraw {
1892
2021
  * then `previewLayer`, then `graphicsLayer` — against each layer's current
1893
2022
  * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
1894
2023
  * excluded: it is non-interactive draw-guide scaffolding, never a
1895
- * legitimate "owned" click target.
2024
+ * legitimate "owned" click target. Within each layer, candidates follow
2025
+ * the shared visible-intent ranking (direct marks, then filled interiors,
2026
+ * then inferred outline-only reach, distance within direct marks, then
2027
+ * visual topmost order) — this explicit-affordance layer order is the
2028
+ * tier above all of it.
1896
2029
  *
1897
2030
  * `preview` and `graphics` each get a label text-extent fallback after the
1898
2031
  * geometry pass misses on that layer: for every `Point` feature carrying a
@@ -1903,7 +2036,7 @@ declare class TacticalDraw {
1903
2036
  * This matters most for the active preview, since a click can land
1904
2037
  * anywhere across a rotated/offset label during an edit.
1905
2038
  *
1906
- * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
2039
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
1907
2040
  * pass the host's raw click event as `originalEvent` to get the same
1908
2041
  * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
1909
2042
  * pass an explicit `tolerance` to override it outright.
@@ -2063,4 +2196,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
2063
2196
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
2064
2197
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
2065
2198
  //#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 };
2199
+ 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
  };
@@ -6116,7 +6467,11 @@ var TacticalDraw = class {
6116
6467
  * then `previewLayer`, then `graphicsLayer` — against each layer's current
6117
6468
  * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
6118
6469
  * excluded: it is non-interactive draw-guide scaffolding, never a
6119
- * legitimate "owned" click target.
6470
+ * legitimate "owned" click target. Within each layer, candidates follow
6471
+ * the shared visible-intent ranking (direct marks, then filled interiors,
6472
+ * then inferred outline-only reach, distance within direct marks, then
6473
+ * visual topmost order) — this explicit-affordance layer order is the
6474
+ * tier above all of it.
6120
6475
  *
6121
6476
  * `preview` and `graphics` each get a label text-extent fallback after the
6122
6477
  * geometry pass misses on that layer: for every `Point` feature carrying a
@@ -6127,7 +6482,7 @@ var TacticalDraw = class {
6127
6482
  * This matters most for the active preview, since a click can land
6128
6483
  * anywhere across a rotated/offset label during an edit.
6129
6484
  *
6130
- * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
6485
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
6131
6486
  * pass the host's raw click event as `originalEvent` to get the same
6132
6487
  * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
6133
6488
  * pass an explicit `tolerance` to override it outright.
@@ -6143,7 +6498,7 @@ var TacticalDraw = class {
6143
6498
  */
6144
6499
  ownsInteractionAt(pixel, options) {
6145
6500
  this.assertAlive();
6146
- const tolerance = options?.tolerance ?? mapEventHitTolerance(options?.originalEvent);
6501
+ const tolerance = options?.tolerance ?? pickHitTolerance(options?.originalEvent);
6147
6502
  const toPixel = (lonLat) => this.adapter.getPixelFromCoordinate(lonLat);
6148
6503
  const layers = [
6149
6504
  {
@@ -6162,20 +6517,48 @@ var TacticalDraw = class {
6162
6517
  labelFallback: true
6163
6518
  }
6164
6519
  ];
6520
+ const size = this.adapter.getViewportSize();
6521
+ const context = {
6522
+ pixel,
6523
+ pointerType: pickPointerType(options?.originalEvent),
6524
+ tolerance,
6525
+ toPixel,
6526
+ viewport: {
6527
+ minX: 0,
6528
+ minY: 0,
6529
+ maxX: size.width,
6530
+ maxY: size.height
6531
+ },
6532
+ bearing: this.adapter.getBearing()
6533
+ };
6165
6534
  for (const { layer, layerId, labelFallback } of layers) {
6166
6535
  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);
6536
+ const geometryCandidate = evaluatePickCandidates(features.map((feature, index) => ({
6537
+ feature,
6538
+ visualOrder: renderOrderToVisualOrder(index, features.length)
6539
+ })), context);
6540
+ if (geometryCandidate) return this.toInteractionHit(layer, geometryCandidate.feature);
6541
+ if (labelFallback) {
6542
+ const labelCandidates = [];
6543
+ for (const feature of features) {
6544
+ if (feature.geometry?.type !== "Point") continue;
6545
+ if (typeof feature.properties?.text !== "string") continue;
6546
+ const anchorPixel = toPixel(feature.geometry.coordinates);
6547
+ if (!anchorPixel) continue;
6548
+ const box = fitLabelBoxFromFeature(feature, anchorPixel, {
6549
+ resolution: this.adapter.getResolution(),
6550
+ bearing: this.adapter.getBearing()
6551
+ });
6552
+ if (box) labelCandidates.push({
6553
+ feature,
6554
+ labelExtent: box
6555
+ });
6556
+ }
6557
+ const labelCandidate = evaluatePickCandidates(labelCandidates.map((candidate, index) => ({
6558
+ ...candidate,
6559
+ visualOrder: renderOrderToVisualOrder(index, labelCandidates.length)
6560
+ })), context);
6561
+ if (labelCandidate) return this.toInteractionHit(layer, labelCandidate.feature);
6179
6562
  }
6180
6563
  }
6181
6564
  return null;
@@ -6593,4 +6976,4 @@ function nearestWorldCopyPosition(position, refLng) {
6593
6976
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
6594
6977
  }
6595
6978
  //#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 };
6979
+ 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.32",
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",