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