@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 +136 -3
- package/dist/index.mjs +491 -108
- package/package.json +1 -1
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 `
|
|
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
|
|
222
|
-
if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent)
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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 =
|
|
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 `
|
|
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 ??
|
|
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
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
const
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
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.
|
|
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",
|