@orbat-mapper/tactical-draw 0.2.0-alpha.16 → 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 +381 -51
- 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
|
/**
|
|
@@ -2024,6 +2033,114 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
|
2024
2033
|
};
|
|
2025
2034
|
}
|
|
2026
2035
|
//#endregion
|
|
2036
|
+
//#region src/tactical-draw/transform-amplifier-placements.ts
|
|
2037
|
+
/**
|
|
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 − Δ`.
|
|
2049
|
+
*/
|
|
2050
|
+
function transformAmplifierPlacements(placements, from, to, projection) {
|
|
2051
|
+
if (!placements) return void 0;
|
|
2052
|
+
const scaleX = to.halfWidth / from.halfWidth;
|
|
2053
|
+
const scaleY = to.halfHeight / from.halfHeight;
|
|
2054
|
+
const angleDelta = to.angle - from.angle;
|
|
2055
|
+
const transformed = {};
|
|
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 };
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
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;
|
|
2073
|
+
}
|
|
2074
|
+
return transformed;
|
|
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
|
+
}
|
|
2143
|
+
//#endregion
|
|
2027
2144
|
//#region src/tactical-draw/edit-controller.ts
|
|
2028
2145
|
/**
|
|
2029
2146
|
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
@@ -2054,6 +2171,25 @@ const ALLOWED_EDIT_MODES = new Set([
|
|
|
2054
2171
|
"delete"
|
|
2055
2172
|
]);
|
|
2056
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
|
+
/**
|
|
2057
2193
|
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
2058
2194
|
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
2059
2195
|
*
|
|
@@ -2136,6 +2272,22 @@ function runEdit(opts) {
|
|
|
2136
2272
|
let box = null;
|
|
2137
2273
|
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
2138
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;
|
|
2139
2291
|
let ctx;
|
|
2140
2292
|
let driver;
|
|
2141
2293
|
const dragProjection = {
|
|
@@ -2217,9 +2369,17 @@ function runEdit(opts) {
|
|
|
2217
2369
|
if (modes.includes("labeldrag")) {
|
|
2218
2370
|
const style = interactionStyle?.labelHandle;
|
|
2219
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
|
+
}
|
|
2220
2379
|
for (const feature of lastPreviewFeatures) {
|
|
2221
2380
|
const key = feature.properties?.labelPlacementKey;
|
|
2222
2381
|
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2382
|
+
if (key === selectedLabelKey) continue;
|
|
2223
2383
|
const pixel = driver.toPixel(feature.geometry.coordinates);
|
|
2224
2384
|
if (!pixel) continue;
|
|
2225
2385
|
features.push({
|
|
@@ -2238,6 +2398,15 @@ function runEdit(opts) {
|
|
|
2238
2398
|
});
|
|
2239
2399
|
index += 1;
|
|
2240
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
|
+
}
|
|
2241
2410
|
}
|
|
2242
2411
|
if (modes.includes("transform")) {
|
|
2243
2412
|
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
@@ -2502,8 +2671,15 @@ function runEdit(opts) {
|
|
|
2502
2671
|
const previousAmplifierPlacements = snapshotAmplifierPlacements();
|
|
2503
2672
|
const previousRendered = rebuildRendered();
|
|
2504
2673
|
const next = { ...workingAmplifierPlacements ?? {} };
|
|
2505
|
-
for (const [field,
|
|
2506
|
-
|
|
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
|
+
}
|
|
2507
2683
|
workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
|
|
2508
2684
|
const currentRendered = rebuildRendered();
|
|
2509
2685
|
renderPreview();
|
|
@@ -2571,6 +2747,7 @@ function runEdit(opts) {
|
|
|
2571
2747
|
function applyModes(next) {
|
|
2572
2748
|
if (modesEqual(modes, next)) return;
|
|
2573
2749
|
modes = next;
|
|
2750
|
+
if (!modes.includes("labeldrag")) selectedLabelKey = null;
|
|
2574
2751
|
renderHandlesLayer();
|
|
2575
2752
|
}
|
|
2576
2753
|
function drainPendingModes() {
|
|
@@ -2670,7 +2847,33 @@ function runEdit(opts) {
|
|
|
2670
2847
|
}
|
|
2671
2848
|
};
|
|
2672
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
|
+
*/
|
|
2673
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
|
+
}
|
|
2674
2877
|
function pickLabel(pixel, tolerance) {
|
|
2675
2878
|
for (const feature of lastPreviewFeatures) {
|
|
2676
2879
|
const key = feature.properties?.labelPlacementKey;
|
|
@@ -2681,21 +2884,58 @@ function runEdit(opts) {
|
|
|
2681
2884
|
const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
|
|
2682
2885
|
if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
|
|
2683
2886
|
key,
|
|
2684
|
-
|
|
2887
|
+
anchorPixel: projected
|
|
2685
2888
|
};
|
|
2686
2889
|
}
|
|
2890
|
+
const box = fitLabelBoxFromFeature(feature, projected);
|
|
2891
|
+
if (box && pointInBox(box, pixel)) return {
|
|
2892
|
+
key,
|
|
2893
|
+
anchorPixel: projected
|
|
2894
|
+
};
|
|
2687
2895
|
}
|
|
2688
2896
|
return null;
|
|
2689
2897
|
}
|
|
2690
2898
|
/**
|
|
2691
|
-
* The label anchor a pointer pixel maps to:
|
|
2692
|
-
*
|
|
2693
|
-
*
|
|
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.
|
|
2694
2903
|
*/
|
|
2695
|
-
function labelAnchorFromPointer(pixel,
|
|
2696
|
-
return driver.fromPixel([pixel[0], pixel[1] +
|
|
2904
|
+
function labelAnchorFromPointer(pixel, grabOffset) {
|
|
2905
|
+
return driver.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
|
|
2697
2906
|
}
|
|
2698
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]);
|
|
2927
|
+
}
|
|
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
|
+
/**
|
|
2699
2939
|
* A live transform-box gesture (corner scale, box rotate, or body
|
|
2700
2940
|
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
2701
2941
|
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
@@ -2704,24 +2944,6 @@ function runEdit(opts) {
|
|
|
2704
2944
|
* `boxAngle`.
|
|
2705
2945
|
*/
|
|
2706
2946
|
let activeBoxDrag = null;
|
|
2707
|
-
function transformAmplifierPlacements(placements, from, to) {
|
|
2708
|
-
if (!placements) return void 0;
|
|
2709
|
-
const scaleX = to.halfWidth / from.halfWidth;
|
|
2710
|
-
const scaleY = to.halfHeight / from.halfHeight;
|
|
2711
|
-
const transformed = {};
|
|
2712
|
-
for (const [field, position] of Object.entries(placements)) {
|
|
2713
|
-
if (position === void 0) continue;
|
|
2714
|
-
const pixel = driver.toPixel(position);
|
|
2715
|
-
if (!pixel) {
|
|
2716
|
-
transformed[field] = [...position];
|
|
2717
|
-
continue;
|
|
2718
|
-
}
|
|
2719
|
-
const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
|
|
2720
|
-
const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
|
|
2721
|
-
transformed[field] = driver.fromPixel([to.center[0] + outX, to.center[1] + outY]);
|
|
2722
|
-
}
|
|
2723
|
-
return transformed;
|
|
2724
|
-
}
|
|
2725
2947
|
/**
|
|
2726
2948
|
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2727
2949
|
* exclusive `delete` mode). Deletion is
|
|
@@ -2761,11 +2983,47 @@ function runEdit(opts) {
|
|
|
2761
2983
|
const onDriverDown = (pixel, info) => {
|
|
2762
2984
|
if (ctx.settled || dragInFlight) return;
|
|
2763
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
|
+
}
|
|
2764
3022
|
const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
|
|
2765
3023
|
if (labelHit) {
|
|
2766
3024
|
activeAmplifierDrag = {
|
|
2767
3025
|
field: labelHit.key,
|
|
2768
|
-
|
|
3026
|
+
grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
|
|
2769
3027
|
previous: snapshotAmplifierPlacements(),
|
|
2770
3028
|
downPixel: pixel
|
|
2771
3029
|
};
|
|
@@ -2859,19 +3117,43 @@ function runEdit(opts) {
|
|
|
2859
3117
|
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
2860
3118
|
activeBoxDrag.lastSnapshot = snapshot;
|
|
2861
3119
|
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2862
|
-
workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box);
|
|
3120
|
+
workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
|
|
2863
3121
|
renderPreview();
|
|
2864
3122
|
renderHandlesLayer();
|
|
2865
3123
|
recomputeDirty();
|
|
2866
3124
|
}
|
|
2867
3125
|
const onDriverMove = (pixel) => {
|
|
2868
3126
|
if (ctx.settled) return;
|
|
2869
|
-
if (
|
|
2870
|
-
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);
|
|
2871
3151
|
if (!position) return;
|
|
3152
|
+
const field = activeAmplifierDrag.field;
|
|
3153
|
+
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
|
|
2872
3154
|
workingAmplifierPlacements = {
|
|
2873
3155
|
...workingAmplifierPlacements ?? {},
|
|
2874
|
-
[
|
|
3156
|
+
...merged !== void 0 ? { [field]: merged } : {}
|
|
2875
3157
|
};
|
|
2876
3158
|
renderPreview();
|
|
2877
3159
|
renderHandlesLayer();
|
|
@@ -2885,17 +3167,35 @@ function runEdit(opts) {
|
|
|
2885
3167
|
drainPendingModes();
|
|
2886
3168
|
}
|
|
2887
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
|
+
}
|
|
2888
3186
|
if (activeAmplifierDrag) {
|
|
2889
|
-
const { field,
|
|
3187
|
+
const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
|
|
2890
3188
|
activeAmplifierDrag = null;
|
|
2891
|
-
const position = labelAnchorFromPointer(pixel,
|
|
3189
|
+
const position = labelAnchorFromPointer(pixel, grabOffset);
|
|
2892
3190
|
workingAmplifierPlacements = previous;
|
|
2893
3191
|
endDragBookkeeping();
|
|
2894
3192
|
if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
|
|
2895
3193
|
else {
|
|
3194
|
+
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
2896
3195
|
renderPreview();
|
|
2897
3196
|
renderHandlesLayer();
|
|
2898
3197
|
}
|
|
3198
|
+
suppressClickAway = true;
|
|
2899
3199
|
return;
|
|
2900
3200
|
}
|
|
2901
3201
|
if (activeDrag) {
|
|
@@ -2938,7 +3238,7 @@ function runEdit(opts) {
|
|
|
2938
3238
|
replaceWorkingPoints(preDragPoints);
|
|
2939
3239
|
workingAmplifierPlacements = preDragAmplifierPlacements;
|
|
2940
3240
|
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
2941
|
-
commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box));
|
|
3241
|
+
commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
|
|
2942
3242
|
return;
|
|
2943
3243
|
}
|
|
2944
3244
|
};
|
|
@@ -3011,11 +3311,12 @@ function runEdit(opts) {
|
|
|
3011
3311
|
unsubDown();
|
|
3012
3312
|
unsubMove();
|
|
3013
3313
|
unsubUp();
|
|
3014
|
-
if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
|
|
3314
|
+
if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
|
|
3015
3315
|
driver.setDragPanEnabled(true);
|
|
3016
3316
|
activeDrag = null;
|
|
3017
3317
|
activeBoxDrag = null;
|
|
3018
3318
|
activeAmplifierDrag = null;
|
|
3319
|
+
activeLabelRotateDrag = null;
|
|
3019
3320
|
dragInFlight = false;
|
|
3020
3321
|
}
|
|
3021
3322
|
driver.dispose();
|
|
@@ -3126,6 +3427,7 @@ function runGroupTransform(opts) {
|
|
|
3126
3427
|
measure,
|
|
3127
3428
|
editStartPoints,
|
|
3128
3429
|
workingPoints: editStartPoints.map((p) => [...p]),
|
|
3430
|
+
workingAmplifierPlacements: measure.amplifierPlacements ? structuredClone(measure.amplifierPlacements) : void 0,
|
|
3129
3431
|
optionsOverride: null,
|
|
3130
3432
|
box: null,
|
|
3131
3433
|
lastPreviewFeatures: []
|
|
@@ -3157,7 +3459,8 @@ function runGroupTransform(opts) {
|
|
|
3157
3459
|
const working = {
|
|
3158
3460
|
...member.measure,
|
|
3159
3461
|
options: mergeOptions(member.measure.options, override),
|
|
3160
|
-
controlPoints: member.workingPoints.map((p) => [...p])
|
|
3462
|
+
controlPoints: member.workingPoints.map((p) => [...p]),
|
|
3463
|
+
...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
|
|
3161
3464
|
};
|
|
3162
3465
|
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
3163
3466
|
return {
|
|
@@ -3171,7 +3474,8 @@ function runGroupTransform(opts) {
|
|
|
3171
3474
|
return {
|
|
3172
3475
|
...member.measure,
|
|
3173
3476
|
options: effectiveOptions(i),
|
|
3174
|
-
controlPoints: member.workingPoints.map((p) => [...p])
|
|
3477
|
+
controlPoints: member.workingPoints.map((p) => [...p]),
|
|
3478
|
+
...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
|
|
3175
3479
|
};
|
|
3176
3480
|
}
|
|
3177
3481
|
let dirty = false;
|
|
@@ -3338,6 +3642,21 @@ function runGroupTransform(opts) {
|
|
|
3338
3642
|
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
3339
3643
|
});
|
|
3340
3644
|
}
|
|
3645
|
+
function cloneAmplifierPlacementsByMember() {
|
|
3646
|
+
return members.map((member) => member.workingAmplifierPlacements ? structuredClone(member.workingAmplifierPlacements) : void 0);
|
|
3647
|
+
}
|
|
3648
|
+
/**
|
|
3649
|
+
* Adopts `placements[i]` by reference — callers hand over ownership (the
|
|
3650
|
+
* sources are freshly built transforms or snapshot arrays that are dead
|
|
3651
|
+
* after the restore), and working placements are only ever replaced
|
|
3652
|
+
* wholesale, never mutated in place. This runs on every pointermove of a
|
|
3653
|
+
* box drag, so it must not deep-clone.
|
|
3654
|
+
*/
|
|
3655
|
+
function applyAmplifierPlacementsByMember(placements) {
|
|
3656
|
+
members.forEach((member, i) => {
|
|
3657
|
+
member.workingAmplifierPlacements = placements[i];
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3341
3660
|
function cloneOptionsOverrideByMember() {
|
|
3342
3661
|
return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
|
|
3343
3662
|
}
|
|
@@ -3369,7 +3688,7 @@ function runGroupTransform(opts) {
|
|
|
3369
3688
|
* instance to all listeners in order, deferred `reject`/`close`/`abort`
|
|
3370
3689
|
* (close/abort override reject), exception collection with `AggregateError`.
|
|
3371
3690
|
*/
|
|
3372
|
-
function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
|
|
3691
|
+
function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
|
|
3373
3692
|
if (ctx.settled) return;
|
|
3374
3693
|
let rejectRequested = false;
|
|
3375
3694
|
let closeRequested = false;
|
|
@@ -3399,6 +3718,7 @@ function runGroupTransform(opts) {
|
|
|
3399
3718
|
else if (closeRequested) commitClose();
|
|
3400
3719
|
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
3401
3720
|
restorePointsByMember(previousPointsByMember);
|
|
3721
|
+
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
3402
3722
|
sizeAnchor = previousSizeAnchor;
|
|
3403
3723
|
nonGeometryDirty = previousNonGeometryDirty;
|
|
3404
3724
|
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
@@ -3431,17 +3751,20 @@ function runGroupTransform(opts) {
|
|
|
3431
3751
|
}
|
|
3432
3752
|
return snapshots;
|
|
3433
3753
|
}
|
|
3434
|
-
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
|
|
3754
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
|
|
3435
3755
|
if (ctx.settled) return;
|
|
3436
3756
|
const previousPointsByMember = clonePointsByMember();
|
|
3757
|
+
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
3437
3758
|
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
3438
3759
|
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
3439
3760
|
applyConcatenatedPoints(nextConcatPoints);
|
|
3761
|
+
if (nextAmplifierPlacementsByMember) applyAmplifierPlacementsByMember(nextAmplifierPlacementsByMember);
|
|
3440
3762
|
let currentRendered;
|
|
3441
3763
|
try {
|
|
3442
3764
|
currentRendered = rebuildRenderedAll();
|
|
3443
3765
|
} catch (err) {
|
|
3444
3766
|
restorePointsByMember(previousPointsByMember);
|
|
3767
|
+
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
3445
3768
|
dragInFlight = false;
|
|
3446
3769
|
throw err;
|
|
3447
3770
|
}
|
|
@@ -3449,7 +3772,7 @@ function runGroupTransform(opts) {
|
|
|
3449
3772
|
renderHandlesLayer();
|
|
3450
3773
|
recomputeDirty();
|
|
3451
3774
|
dragInFlight = false;
|
|
3452
|
-
emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3775
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3453
3776
|
}
|
|
3454
3777
|
function setSizeAnchor(next) {
|
|
3455
3778
|
if (ctx.settled || sizeAnchor === next) return;
|
|
@@ -3457,12 +3780,13 @@ function runGroupTransform(opts) {
|
|
|
3457
3780
|
const previousSizeAnchor = sizeAnchor;
|
|
3458
3781
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
3459
3782
|
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3783
|
+
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
3460
3784
|
const previousRendered = rebuildRenderedAll();
|
|
3461
3785
|
sizeAnchor = next;
|
|
3462
3786
|
const currentRendered = rebuildRenderedAll();
|
|
3463
3787
|
nonGeometryDirty = true;
|
|
3464
3788
|
dirty = true;
|
|
3465
|
-
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3789
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3466
3790
|
}
|
|
3467
3791
|
/**
|
|
3468
3792
|
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
@@ -3474,10 +3798,11 @@ function runGroupTransform(opts) {
|
|
|
3474
3798
|
*/
|
|
3475
3799
|
function cancelActiveBoxDrag() {
|
|
3476
3800
|
if (!activeBoxDrag) return;
|
|
3477
|
-
const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3801
|
+
const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3478
3802
|
activeBoxDrag = null;
|
|
3479
3803
|
driver.setDragPanEnabled(true);
|
|
3480
3804
|
applyConcatenatedPoints(preDragPoints);
|
|
3805
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
3481
3806
|
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
3482
3807
|
dragInFlight = false;
|
|
3483
3808
|
}
|
|
@@ -3567,6 +3892,7 @@ function runGroupTransform(opts) {
|
|
|
3567
3892
|
renderHandlesLayer();
|
|
3568
3893
|
}
|
|
3569
3894
|
const preDragPoints = concatWorkingPoints();
|
|
3895
|
+
const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
3570
3896
|
activeBoxDrag = {
|
|
3571
3897
|
fsm: beginBoxDrag({
|
|
3572
3898
|
kind: boxHit.kind,
|
|
@@ -3583,7 +3909,8 @@ function runGroupTransform(opts) {
|
|
|
3583
3909
|
points: preDragPoints,
|
|
3584
3910
|
box: groupBox
|
|
3585
3911
|
},
|
|
3586
|
-
preDragOptionsOverrideByMember
|
|
3912
|
+
preDragOptionsOverrideByMember,
|
|
3913
|
+
preDragAmplifierPlacementsByMember
|
|
3587
3914
|
};
|
|
3588
3915
|
dragInFlight = true;
|
|
3589
3916
|
driver.setDragPanEnabled(false);
|
|
@@ -3593,6 +3920,7 @@ function runGroupTransform(opts) {
|
|
|
3593
3920
|
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
3594
3921
|
activeBoxDrag.lastSnapshot = snapshot;
|
|
3595
3922
|
applyConcatenatedPoints(snapshot.points);
|
|
3923
|
+
applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
|
|
3596
3924
|
renderAllPreviews();
|
|
3597
3925
|
renderHandlesLayer();
|
|
3598
3926
|
recomputeDirty();
|
|
@@ -3609,12 +3937,13 @@ function runGroupTransform(opts) {
|
|
|
3609
3937
|
driver.setDragPanEnabled(true);
|
|
3610
3938
|
return;
|
|
3611
3939
|
}
|
|
3612
|
-
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3940
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember } = activeBoxDrag;
|
|
3613
3941
|
activeBoxDrag = null;
|
|
3614
3942
|
driver.setDragPanEnabled(true);
|
|
3615
3943
|
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
3616
3944
|
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
|
|
3617
3945
|
applyConcatenatedPoints(preDragPoints);
|
|
3946
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
3618
3947
|
renderAllPreviews();
|
|
3619
3948
|
renderHandlesLayer();
|
|
3620
3949
|
recomputeDirty();
|
|
@@ -3624,8 +3953,9 @@ function runGroupTransform(opts) {
|
|
|
3624
3953
|
}
|
|
3625
3954
|
const finalSnapshot = fsm.move(pixel);
|
|
3626
3955
|
applyConcatenatedPoints(preDragPoints);
|
|
3956
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
3627
3957
|
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
3628
|
-
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
|
|
3958
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)));
|
|
3629
3959
|
};
|
|
3630
3960
|
let pickPreview;
|
|
3631
3961
|
let pickHandles;
|
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",
|