@orbat-mapper/tactical-draw 0.2.0-alpha.17 → 0.2.0-alpha.18
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 +21 -3
- package/dist/index.mjs +326 -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
|
*/
|
|
@@ -328,6 +334,11 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
|
|
|
328
334
|
abstract setDoubleClickZoomEnabled(enabled: boolean): void;
|
|
329
335
|
abstract getResolution(): number | undefined;
|
|
330
336
|
abstract getZoom(): number | undefined;
|
|
337
|
+
/**
|
|
338
|
+
* Default for engines/views that don't rotate (Leaflet). Rotating engines
|
|
339
|
+
* (MapLibre, OpenLayers) override this.
|
|
340
|
+
*/
|
|
341
|
+
getBearing(): number;
|
|
331
342
|
}
|
|
332
343
|
//#endregion
|
|
333
344
|
//#region src/pick-utils.d.ts
|
|
@@ -620,8 +631,15 @@ interface EditSession {
|
|
|
620
631
|
* silent no-op after the session has settled (like [[setOptions]]).
|
|
621
632
|
*/
|
|
622
633
|
setTextAmplifiers(partial: Record<string, string | undefined>): void;
|
|
623
|
-
/**
|
|
624
|
-
|
|
634
|
+
/**
|
|
635
|
+
* Merge field-keyed label overrides (position and/or rotation). The merge is
|
|
636
|
+
* **field-wise per key**: a bare `Position` value means `{ position }`;
|
|
637
|
+
* fields absent from the incoming value (e.g. `rotation` when only
|
|
638
|
+
* `position` is supplied) are preserved from the existing entry; an
|
|
639
|
+
* explicit `undefined` value deletes the key entirely — a full reset to the
|
|
640
|
+
* doctrinal placement (both position and rotation).
|
|
641
|
+
*/
|
|
642
|
+
setAmplifierPlacements(partial: Record<string, AmplifierPlacement | undefined>): void;
|
|
625
643
|
/**
|
|
626
644
|
* Subscribe to working-copy changes. The listener receives an
|
|
627
645
|
* [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
|
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,13 @@ 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
|
+
}
|
|
176
183
|
};
|
|
177
184
|
function pickHitTolerance(originalEvent) {
|
|
178
185
|
if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
|
|
@@ -1688,13 +1695,15 @@ function boxCorners(box) {
|
|
|
1688
1695
|
});
|
|
1689
1696
|
}
|
|
1690
1697
|
/**
|
|
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
|
|
1698
|
+
* Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
|
|
1699
|
+
* above the top-edge midpoint, along the box's local "up" direction, so the
|
|
1700
|
+
* grip stays glued to the box (rotates with it) rather than floating at a
|
|
1701
|
+
* fixed screen offset. The label transform box passes its own tighter
|
|
1702
|
+
* clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
|
|
1694
1703
|
*/
|
|
1695
|
-
function rotateGripPixel(box) {
|
|
1704
|
+
function rotateGripPixel(box, offset = 28) {
|
|
1696
1705
|
const { center, halfHeight, angle } = box;
|
|
1697
|
-
const [rx, ry] = rotate(0, -(halfHeight +
|
|
1706
|
+
const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
|
|
1698
1707
|
return [center[0] + rx, center[1] + ry];
|
|
1699
1708
|
}
|
|
1700
1709
|
/**
|
|
@@ -2026,28 +2035,111 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
|
2026
2035
|
//#endregion
|
|
2027
2036
|
//#region src/tactical-draw/transform-amplifier-placements.ts
|
|
2028
2037
|
/**
|
|
2029
|
-
* Carry user-adjusted label anchors
|
|
2030
|
-
* are transformed in pixel space alongside the
|
|
2031
|
-
* remains visually rigid across map
|
|
2038
|
+
* Carry user-adjusted label anchors (and rotation overrides) through a
|
|
2039
|
+
* transform-box gesture. Anchors are transformed in pixel space alongside the
|
|
2040
|
+
* control points so the result remains visually rigid across map
|
|
2041
|
+
* projections.
|
|
2042
|
+
*
|
|
2043
|
+
* Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
|
|
2044
|
+
* clockwise on screen (see `transform-box.ts`). Adapters render a label's
|
|
2045
|
+
* on-screen clockwise angle as `π − rotation`, and a rotation delta is
|
|
2046
|
+
* invariant under the map→screen bearing, so an on-screen delta
|
|
2047
|
+
* `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
|
|
2048
|
+
* i.e. `rotation' = rotation − Δ`.
|
|
2032
2049
|
*/
|
|
2033
2050
|
function transformAmplifierPlacements(placements, from, to, projection) {
|
|
2034
2051
|
if (!placements) return void 0;
|
|
2035
2052
|
const scaleX = to.halfWidth / from.halfWidth;
|
|
2036
2053
|
const scaleY = to.halfHeight / from.halfHeight;
|
|
2054
|
+
const angleDelta = to.angle - from.angle;
|
|
2037
2055
|
const transformed = {};
|
|
2038
|
-
for (const [field,
|
|
2039
|
-
|
|
2040
|
-
const
|
|
2041
|
-
if (!
|
|
2042
|
-
transformed[field] =
|
|
2056
|
+
for (const [field, raw] of Object.entries(placements)) {
|
|
2057
|
+
const resolved = resolveAmplifierPlacement(raw);
|
|
2058
|
+
const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
|
|
2059
|
+
if (!resolved.position) {
|
|
2060
|
+
if (rotation !== void 0) transformed[field] = { rotation };
|
|
2043
2061
|
continue;
|
|
2044
2062
|
}
|
|
2045
|
-
const
|
|
2046
|
-
const
|
|
2047
|
-
|
|
2063
|
+
const pixel = projection.toPixel(resolved.position);
|
|
2064
|
+
const position = pixel ? (() => {
|
|
2065
|
+
const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
|
|
2066
|
+
const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
|
|
2067
|
+
return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
|
|
2068
|
+
})() : [...resolved.position];
|
|
2069
|
+
transformed[field] = rotation !== void 0 ? {
|
|
2070
|
+
position,
|
|
2071
|
+
rotation
|
|
2072
|
+
} : position;
|
|
2048
2073
|
}
|
|
2049
2074
|
return transformed;
|
|
2050
2075
|
}
|
|
2076
|
+
/** Estimated glyph width as a fraction of text height — a monospace-ish approximation, not a real text-metrics measurement. */
|
|
2077
|
+
const CHAR_WIDTH_FACTOR = .6;
|
|
2078
|
+
/** Estimated line height as a multiple of text height. */
|
|
2079
|
+
const LINE_HEIGHT_FACTOR = 1.2;
|
|
2080
|
+
/**
|
|
2081
|
+
* Normalize `angle` (radians) into `(-π, π]`, matching the range
|
|
2082
|
+
* `Math.atan2`/screen-angle math elsewhere in `tactical-draw` produces.
|
|
2083
|
+
*/
|
|
2084
|
+
function normalizeAngle(angle) {
|
|
2085
|
+
const twoPi = Math.PI * 2;
|
|
2086
|
+
let a = angle % twoPi;
|
|
2087
|
+
if (a <= -Math.PI) a += twoPi;
|
|
2088
|
+
else if (a > Math.PI) a -= twoPi;
|
|
2089
|
+
return a;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Fit a `TransformBox` around `input`'s label. Returns `null` when `text` is
|
|
2093
|
+
* empty/absent — there is nothing to box.
|
|
2094
|
+
*
|
|
2095
|
+
* - **Angle**: on-screen clockwise, derived from the stored `rotation` by the
|
|
2096
|
+
* same `π − rotation` formula adapters use to render the label (see
|
|
2097
|
+
* `ml-adapter.ts`'s `buildSymbolLayer`), further offset by `−bearing` since
|
|
2098
|
+
* labels render map-aligned (`text-rotation-alignment: "map"` / OL
|
|
2099
|
+
* `rotateWithView: true`) and rotate with the view, so the box tilts
|
|
2100
|
+
* exactly as the rendered text does.
|
|
2101
|
+
* - **Size**: `text` is split on `"\n"`; the longest line's character count
|
|
2102
|
+
* times `CHAR_WIDTH_FACTOR × h` gives the width, the line count times
|
|
2103
|
+
* `LINE_HEIGHT_FACTOR × h` gives the height, where `h` is the resolved
|
|
2104
|
+
* text-height-in-pixels, mirroring how the adapters render each anchor
|
|
2105
|
+
* kind (ADR-0028): screen-anchored `textSizePixels` is used as-is,
|
|
2106
|
+
* unclamped (only a `1px` sanity floor to keep the box non-degenerate);
|
|
2107
|
+
* ground-anchored `textSizeMeters / resolution` is clamped to the default
|
|
2108
|
+
* `[DEFAULT_TEXT_SIZE_MIN_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS]` band via
|
|
2109
|
+
* `resolveGroundTextSizePixels` (`feature-style.ts`); absent both,
|
|
2110
|
+
* `DEFAULT_LABEL_TEXT_SIZE_PX`. `LABEL_BOX_PADDING_PX` is added on every
|
|
2111
|
+
* side. Hosts can override the clamp band per layer style
|
|
2112
|
+
* (`TextStyleOptions.textSizeMinPixels`/`textSizeMaxPixels`), which this
|
|
2113
|
+
* estimate can't see — the default band keeps the box honest at extreme
|
|
2114
|
+
* zooms even when a host has customized it.
|
|
2115
|
+
* - **Center**: `anchor` for centered text (`textAnchor` absent); offset by
|
|
2116
|
+
* half the fitted width along the label's local +x for `"start"` (text
|
|
2117
|
+
* grows away from the anchor) or local −x for `"end"` (text grows back
|
|
2118
|
+
* toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
|
|
2119
|
+
* screen (y-down) convention this offset needs.
|
|
2120
|
+
*/
|
|
2121
|
+
function fitLabelTransformBox(input) {
|
|
2122
|
+
const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
|
|
2123
|
+
if (!text) return null;
|
|
2124
|
+
const angle = normalizeAngle(Math.PI - (rotation ?? 0) - (bearing ?? 0));
|
|
2125
|
+
const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
|
|
2126
|
+
const lines = text.split("\n");
|
|
2127
|
+
const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
|
|
2128
|
+
const height = lines.length * LINE_HEIGHT_FACTOR * h;
|
|
2129
|
+
const halfWidth = width / 2 + 4;
|
|
2130
|
+
const halfHeight = height / 2 + 4;
|
|
2131
|
+
let center = anchor;
|
|
2132
|
+
if (textAnchor === "start" || textAnchor === "end") {
|
|
2133
|
+
const [ox, oy] = rotate((textAnchor === "start" ? 1 : -1) * width / 2, 0, angle);
|
|
2134
|
+
center = [anchor[0] + ox, anchor[1] + oy];
|
|
2135
|
+
}
|
|
2136
|
+
return {
|
|
2137
|
+
center,
|
|
2138
|
+
halfWidth,
|
|
2139
|
+
halfHeight,
|
|
2140
|
+
angle
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2051
2143
|
//#endregion
|
|
2052
2144
|
//#region src/tactical-draw/edit-controller.ts
|
|
2053
2145
|
/**
|
|
@@ -2079,6 +2171,25 @@ const ALLOWED_EDIT_MODES = new Set([
|
|
|
2079
2171
|
"delete"
|
|
2080
2172
|
]);
|
|
2081
2173
|
/**
|
|
2174
|
+
* Field-wise merge of one `amplifierPlacements` entry: fields present in
|
|
2175
|
+
* `incoming` (valid position / finite rotation) replace the same field on
|
|
2176
|
+
* `existing`; fields absent from `incoming` are preserved. Returns the
|
|
2177
|
+
* *storage shape* for the merged entry — a bare `Position` when no rotation
|
|
2178
|
+
* survives (matching the legacy shorthand), the object form when a rotation
|
|
2179
|
+
* is present, or `undefined` when nothing survives.
|
|
2180
|
+
*/
|
|
2181
|
+
function mergeAmplifierPlacementField(existing, incoming) {
|
|
2182
|
+
const merged = {
|
|
2183
|
+
...resolveAmplifierPlacement(existing),
|
|
2184
|
+
...resolveAmplifierPlacement(incoming)
|
|
2185
|
+
};
|
|
2186
|
+
if (merged.rotation === void 0) return merged.position ? [...merged.position] : void 0;
|
|
2187
|
+
return merged.position ? {
|
|
2188
|
+
position: [...merged.position],
|
|
2189
|
+
rotation: merged.rotation
|
|
2190
|
+
} : { rotation: merged.rotation };
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2082
2193
|
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
2083
2194
|
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
2084
2195
|
*
|
|
@@ -2161,6 +2272,22 @@ function runEdit(opts) {
|
|
|
2161
2272
|
let box = null;
|
|
2162
2273
|
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
2163
2274
|
let lastPreviewFeatures = [];
|
|
2275
|
+
/**
|
|
2276
|
+
* The currently selected label's `labelPlacementKey` in `labeldrag` mode, or
|
|
2277
|
+
* `null` when no label is selected. A click on a label toggles this (select
|
|
2278
|
+
* / deselect / switch — see `onDriverUp`'s amplifier-drag click path); it is
|
|
2279
|
+
* cleared whenever the feature disappears from `lastPreviewFeatures` or the
|
|
2280
|
+
* mode set drops `labeldrag` (see `applyModes`).
|
|
2281
|
+
*/
|
|
2282
|
+
let selectedLabelKey = null;
|
|
2283
|
+
/**
|
|
2284
|
+
* The transform box fitted around the selected label (`label-transform-box.ts`),
|
|
2285
|
+
* re-fitted on every `renderHandlesLayer` call from `lastPreviewFeatures` —
|
|
2286
|
+
* except while a label rotate drag is in flight, when it is painted rigidly
|
|
2287
|
+
* from the drag's own live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`).
|
|
2288
|
+
* `null` when nothing is selected or the selection couldn't be fitted this frame.
|
|
2289
|
+
*/
|
|
2290
|
+
let labelBox = null;
|
|
2164
2291
|
let ctx;
|
|
2165
2292
|
let driver;
|
|
2166
2293
|
const dragProjection = {
|
|
@@ -2242,9 +2369,17 @@ function runEdit(opts) {
|
|
|
2242
2369
|
if (modes.includes("labeldrag")) {
|
|
2243
2370
|
const style = interactionStyle?.labelHandle;
|
|
2244
2371
|
let index = 0;
|
|
2372
|
+
labelBox = null;
|
|
2373
|
+
if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
|
|
2374
|
+
else {
|
|
2375
|
+
const anchor = selectedLabelAnchor();
|
|
2376
|
+
if (selectedLabelKey && !anchor) selectedLabelKey = null;
|
|
2377
|
+
else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel);
|
|
2378
|
+
}
|
|
2245
2379
|
for (const feature of lastPreviewFeatures) {
|
|
2246
2380
|
const key = feature.properties?.labelPlacementKey;
|
|
2247
2381
|
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2382
|
+
if (key === selectedLabelKey) continue;
|
|
2248
2383
|
const pixel = driver.toPixel(feature.geometry.coordinates);
|
|
2249
2384
|
if (!pixel) continue;
|
|
2250
2385
|
features.push({
|
|
@@ -2263,6 +2398,15 @@ function runEdit(opts) {
|
|
|
2263
2398
|
});
|
|
2264
2399
|
index += 1;
|
|
2265
2400
|
}
|
|
2401
|
+
if (labelBox) {
|
|
2402
|
+
features.push(boxOutlineFeature(labelBox, dragProjection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
|
|
2403
|
+
const rotateHandle = {
|
|
2404
|
+
kind: "rotate",
|
|
2405
|
+
index: 0,
|
|
2406
|
+
position: dragProjection.fromPixel(rotateGripPixel(labelBox, 14))
|
|
2407
|
+
};
|
|
2408
|
+
features.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
|
|
2409
|
+
}
|
|
2266
2410
|
}
|
|
2267
2411
|
if (modes.includes("transform")) {
|
|
2268
2412
|
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
@@ -2527,8 +2671,15 @@ function runEdit(opts) {
|
|
|
2527
2671
|
const previousAmplifierPlacements = snapshotAmplifierPlacements();
|
|
2528
2672
|
const previousRendered = rebuildRendered();
|
|
2529
2673
|
const next = { ...workingAmplifierPlacements ?? {} };
|
|
2530
|
-
for (const [field,
|
|
2531
|
-
|
|
2674
|
+
for (const [field, value] of Object.entries(partial)) {
|
|
2675
|
+
if (value === void 0) {
|
|
2676
|
+
delete next[field];
|
|
2677
|
+
continue;
|
|
2678
|
+
}
|
|
2679
|
+
const merged = mergeAmplifierPlacementField(next[field], value);
|
|
2680
|
+
if (merged === void 0) delete next[field];
|
|
2681
|
+
else next[field] = merged;
|
|
2682
|
+
}
|
|
2532
2683
|
workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
|
|
2533
2684
|
const currentRendered = rebuildRendered();
|
|
2534
2685
|
renderPreview();
|
|
@@ -2596,6 +2747,7 @@ function runEdit(opts) {
|
|
|
2596
2747
|
function applyModes(next) {
|
|
2597
2748
|
if (modesEqual(modes, next)) return;
|
|
2598
2749
|
modes = next;
|
|
2750
|
+
if (!modes.includes("labeldrag")) selectedLabelKey = null;
|
|
2599
2751
|
renderHandlesLayer();
|
|
2600
2752
|
}
|
|
2601
2753
|
function drainPendingModes() {
|
|
@@ -2695,7 +2847,33 @@ function runEdit(opts) {
|
|
|
2695
2847
|
}
|
|
2696
2848
|
};
|
|
2697
2849
|
let activeDrag = null;
|
|
2850
|
+
/**
|
|
2851
|
+
* A live label *move* drag: direct label grab, the floating grip, or a body
|
|
2852
|
+
* grab inside the selected label's transform box (ADR-0017 box machinery
|
|
2853
|
+
* applied to labels — see `label-transform-box.ts`) — all three funnel
|
|
2854
|
+
* through this one state, distinguished only by `grabOffset`. `grabOffset`
|
|
2855
|
+
* is the anchor-pixel-minus-down-pixel delta captured at pointer-down, so
|
|
2856
|
+
* `labelAnchorFromPointer` can re-derive the intended anchor from any
|
|
2857
|
+
* subsequent pointer pixel without the label snapping to the pointer's
|
|
2858
|
+
* exact position (a direct grab derives `[0, 0]`; the floating grip derives
|
|
2859
|
+
* `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab derives whatever offset the
|
|
2860
|
+
* click landed at within the box).
|
|
2861
|
+
*/
|
|
2698
2862
|
let activeAmplifierDrag = null;
|
|
2863
|
+
/** Read a label feature's rendered props into a `fitLabelTransformBox` input, anchored at `anchorPixel`. */
|
|
2864
|
+
function fitLabelBoxFromFeature(feature, anchorPixel) {
|
|
2865
|
+
const props = feature.properties ?? {};
|
|
2866
|
+
return fitLabelTransformBox({
|
|
2867
|
+
text: typeof props.text === "string" ? props.text : void 0,
|
|
2868
|
+
rotation: typeof props.rotation === "number" ? props.rotation : void 0,
|
|
2869
|
+
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
2870
|
+
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
2871
|
+
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
2872
|
+
anchor: anchorPixel,
|
|
2873
|
+
resolution: adapter.getResolution(),
|
|
2874
|
+
bearing: adapter.getBearing()
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2699
2877
|
function pickLabel(pixel, tolerance) {
|
|
2700
2878
|
for (const feature of lastPreviewFeatures) {
|
|
2701
2879
|
const key = feature.properties?.labelPlacementKey;
|
|
@@ -2706,21 +2884,58 @@ function runEdit(opts) {
|
|
|
2706
2884
|
const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
|
|
2707
2885
|
if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
|
|
2708
2886
|
key,
|
|
2709
|
-
|
|
2887
|
+
anchorPixel: projected
|
|
2710
2888
|
};
|
|
2711
2889
|
}
|
|
2890
|
+
const box = fitLabelBoxFromFeature(feature, projected);
|
|
2891
|
+
if (box && pointInBox(box, pixel)) return {
|
|
2892
|
+
key,
|
|
2893
|
+
anchorPixel: projected
|
|
2894
|
+
};
|
|
2712
2895
|
}
|
|
2713
2896
|
return null;
|
|
2714
2897
|
}
|
|
2715
2898
|
/**
|
|
2716
|
-
* The label anchor a pointer pixel maps to:
|
|
2717
|
-
*
|
|
2718
|
-
*
|
|
2899
|
+
* The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
|
|
2900
|
+
* `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
|
|
2901
|
+
* see `activeAmplifierDrag`), so the label keeps whatever offset it was
|
|
2902
|
+
* grabbed at instead of snapping to the pointer.
|
|
2719
2903
|
*/
|
|
2720
|
-
function labelAnchorFromPointer(pixel,
|
|
2721
|
-
return driver.fromPixel([pixel[0], pixel[1] +
|
|
2904
|
+
function labelAnchorFromPointer(pixel, grabOffset) {
|
|
2905
|
+
return driver.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* The selected label's rendered feature + its current anchor pixel, or
|
|
2909
|
+
* `null` when nothing is selected, the feature is gone from this frame's
|
|
2910
|
+
* preview, or its anchor fails to project. The single lookup shared by the
|
|
2911
|
+
* box fit (`renderHandlesLayer`) and the box-pick paths (`onDriverDown`).
|
|
2912
|
+
*/
|
|
2913
|
+
function selectedLabelAnchor() {
|
|
2914
|
+
if (!selectedLabelKey) return null;
|
|
2915
|
+
const feature = lastPreviewFeatures.find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
|
|
2916
|
+
if (!feature || feature.geometry?.type !== "Point") return null;
|
|
2917
|
+
const pixel = driver.toPixel(feature.geometry.coordinates);
|
|
2918
|
+
if (!pixel) return null;
|
|
2919
|
+
return {
|
|
2920
|
+
feature,
|
|
2921
|
+
pixel
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
/** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
|
|
2925
|
+
function pointerAngleAround(pixel, anchor) {
|
|
2926
|
+
return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
2722
2927
|
}
|
|
2723
2928
|
/**
|
|
2929
|
+
* A live label *rotate* drag (ADR-0017 box machinery applied to a label's
|
|
2930
|
+
* rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
|
|
2931
|
+
* `textSizeMeters` are captured at pointer-down so `lastBox` can be
|
|
2932
|
+
* re-fitted purely from drag math on every move tick — independent of
|
|
2933
|
+
* `lastPreviewFeatures`, which mirrors `activeBoxDrag.lastSnapshot` and
|
|
2934
|
+
* keeps the box painted even if a transient preview render fails
|
|
2935
|
+
* (`renderPreview`'s try/catch).
|
|
2936
|
+
*/
|
|
2937
|
+
let activeLabelRotateDrag = null;
|
|
2938
|
+
/**
|
|
2724
2939
|
* A live transform-box gesture (corner scale, box rotate, or body
|
|
2725
2940
|
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
2726
2941
|
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
@@ -2768,11 +2983,47 @@ function runEdit(opts) {
|
|
|
2768
2983
|
const onDriverDown = (pixel, info) => {
|
|
2769
2984
|
if (ctx.settled || dragInFlight) return;
|
|
2770
2985
|
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
|
|
2986
|
+
if (modes.includes("labeldrag") && labelBox) {
|
|
2987
|
+
const anchor = selectedLabelAnchor();
|
|
2988
|
+
if (anchor) {
|
|
2989
|
+
const gripPixel = rotateGripPixel(labelBox, 14);
|
|
2990
|
+
if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tol) {
|
|
2991
|
+
const props = anchor.feature.properties ?? {};
|
|
2992
|
+
activeLabelRotateDrag = {
|
|
2993
|
+
key: selectedLabelKey,
|
|
2994
|
+
anchorPixel: anchor.pixel,
|
|
2995
|
+
startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
|
|
2996
|
+
startRotation: typeof props.rotation === "number" ? props.rotation : 0,
|
|
2997
|
+
previous: snapshotAmplifierPlacements(),
|
|
2998
|
+
downPixel: pixel,
|
|
2999
|
+
text: typeof props.text === "string" ? props.text : void 0,
|
|
3000
|
+
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
3001
|
+
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
3002
|
+
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
3003
|
+
lastBox: labelBox
|
|
3004
|
+
};
|
|
3005
|
+
dragInFlight = true;
|
|
3006
|
+
driver.setDragPanEnabled(false);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
if (pointInBox(labelBox, pixel)) {
|
|
3010
|
+
activeAmplifierDrag = {
|
|
3011
|
+
field: selectedLabelKey,
|
|
3012
|
+
grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
|
|
3013
|
+
previous: snapshotAmplifierPlacements(),
|
|
3014
|
+
downPixel: pixel
|
|
3015
|
+
};
|
|
3016
|
+
dragInFlight = true;
|
|
3017
|
+
driver.setDragPanEnabled(false);
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
2771
3022
|
const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
|
|
2772
3023
|
if (labelHit) {
|
|
2773
3024
|
activeAmplifierDrag = {
|
|
2774
3025
|
field: labelHit.key,
|
|
2775
|
-
|
|
3026
|
+
grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
|
|
2776
3027
|
previous: snapshotAmplifierPlacements(),
|
|
2777
3028
|
downPixel: pixel
|
|
2778
3029
|
};
|
|
@@ -2873,12 +3124,36 @@ function runEdit(opts) {
|
|
|
2873
3124
|
}
|
|
2874
3125
|
const onDriverMove = (pixel) => {
|
|
2875
3126
|
if (ctx.settled) return;
|
|
2876
|
-
if (
|
|
2877
|
-
const
|
|
3127
|
+
if (activeLabelRotateDrag) {
|
|
3128
|
+
const drag = activeLabelRotateDrag;
|
|
3129
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
3130
|
+
const rotation = drag.startRotation - delta;
|
|
3131
|
+
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[drag.key], { rotation });
|
|
3132
|
+
workingAmplifierPlacements = {
|
|
3133
|
+
...workingAmplifierPlacements ?? {},
|
|
3134
|
+
...merged !== void 0 ? { [drag.key]: merged } : {}
|
|
3135
|
+
};
|
|
3136
|
+
const liveBox = fitLabelTransformBox({
|
|
3137
|
+
text: drag.text,
|
|
3138
|
+
rotation,
|
|
3139
|
+
textAnchor: drag.textAnchor,
|
|
3140
|
+
textSizePixels: drag.textSizePixels,
|
|
3141
|
+
textSizeMeters: drag.textSizeMeters,
|
|
3142
|
+
anchor: drag.anchorPixel,
|
|
3143
|
+
resolution: adapter.getResolution(),
|
|
3144
|
+
bearing: adapter.getBearing()
|
|
3145
|
+
});
|
|
3146
|
+
if (liveBox) drag.lastBox = liveBox;
|
|
3147
|
+
renderPreview();
|
|
3148
|
+
renderHandlesLayer();
|
|
3149
|
+
} else if (activeAmplifierDrag) {
|
|
3150
|
+
const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
|
|
2878
3151
|
if (!position) return;
|
|
3152
|
+
const field = activeAmplifierDrag.field;
|
|
3153
|
+
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
|
|
2879
3154
|
workingAmplifierPlacements = {
|
|
2880
3155
|
...workingAmplifierPlacements ?? {},
|
|
2881
|
-
[
|
|
3156
|
+
...merged !== void 0 ? { [field]: merged } : {}
|
|
2882
3157
|
};
|
|
2883
3158
|
renderPreview();
|
|
2884
3159
|
renderHandlesLayer();
|
|
@@ -2892,17 +3167,35 @@ function runEdit(opts) {
|
|
|
2892
3167
|
drainPendingModes();
|
|
2893
3168
|
}
|
|
2894
3169
|
const onDriverUp = (pixel) => {
|
|
3170
|
+
if (activeLabelRotateDrag) {
|
|
3171
|
+
const drag = activeLabelRotateDrag;
|
|
3172
|
+
activeLabelRotateDrag = null;
|
|
3173
|
+
workingAmplifierPlacements = drag.previous;
|
|
3174
|
+
endDragBookkeeping();
|
|
3175
|
+
if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$1) {
|
|
3176
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
3177
|
+
const rotation = drag.startRotation - delta;
|
|
3178
|
+
setAmplifierPlacements({ [drag.key]: { rotation } });
|
|
3179
|
+
} else {
|
|
3180
|
+
renderPreview();
|
|
3181
|
+
renderHandlesLayer();
|
|
3182
|
+
}
|
|
3183
|
+
suppressClickAway = true;
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
2895
3186
|
if (activeAmplifierDrag) {
|
|
2896
|
-
const { field,
|
|
3187
|
+
const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
|
|
2897
3188
|
activeAmplifierDrag = null;
|
|
2898
|
-
const position = labelAnchorFromPointer(pixel,
|
|
3189
|
+
const position = labelAnchorFromPointer(pixel, grabOffset);
|
|
2899
3190
|
workingAmplifierPlacements = previous;
|
|
2900
3191
|
endDragBookkeeping();
|
|
2901
3192
|
if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
|
|
2902
3193
|
else {
|
|
3194
|
+
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
2903
3195
|
renderPreview();
|
|
2904
3196
|
renderHandlesLayer();
|
|
2905
3197
|
}
|
|
3198
|
+
suppressClickAway = true;
|
|
2906
3199
|
return;
|
|
2907
3200
|
}
|
|
2908
3201
|
if (activeDrag) {
|
|
@@ -3018,11 +3311,12 @@ function runEdit(opts) {
|
|
|
3018
3311
|
unsubDown();
|
|
3019
3312
|
unsubMove();
|
|
3020
3313
|
unsubUp();
|
|
3021
|
-
if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
|
|
3314
|
+
if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
|
|
3022
3315
|
driver.setDragPanEnabled(true);
|
|
3023
3316
|
activeDrag = null;
|
|
3024
3317
|
activeBoxDrag = null;
|
|
3025
3318
|
activeAmplifierDrag = null;
|
|
3319
|
+
activeLabelRotateDrag = null;
|
|
3026
3320
|
dragInFlight = false;
|
|
3027
3321
|
}
|
|
3028
3322
|
driver.dispose();
|
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.18",
|
|
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",
|