@orbat-mapper/tactical-draw 0.2.0-alpha.24 → 0.2.0-alpha.26
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 +87 -69
- package/dist/index.mjs +1228 -1519
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -461,6 +461,28 @@ function isPointSymbol(graphic) {
|
|
|
461
461
|
function clonePointSymbol(symbol) {
|
|
462
462
|
return structuredClone(symbol);
|
|
463
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Replace a point-symbol size value as a user-driven resize. Ground-size display
|
|
466
|
+
* bounds scale with the value so they continue to constrain zoom changes rather
|
|
467
|
+
* than preventing the resize itself.
|
|
468
|
+
*/
|
|
469
|
+
function resizePointSymbolSize(size, value) {
|
|
470
|
+
if (size.unit === "pixels") return {
|
|
471
|
+
value,
|
|
472
|
+
unit: "pixels"
|
|
473
|
+
};
|
|
474
|
+
const scale = value / size.value;
|
|
475
|
+
if (!Number.isFinite(scale) || scale <= 0) return {
|
|
476
|
+
...size,
|
|
477
|
+
value
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
...size,
|
|
481
|
+
value,
|
|
482
|
+
...size.minPixels !== void 0 ? { minPixels: size.minPixels * scale } : {},
|
|
483
|
+
...size.maxPixels !== void 0 ? { maxPixels: size.maxPixels * scale } : {}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
464
486
|
//#endregion
|
|
465
487
|
//#region src/tactical-draw/active-interaction.ts
|
|
466
488
|
/**
|
|
@@ -1427,6 +1449,71 @@ function createSettleEmitter(listenerErrorsMessage) {
|
|
|
1427
1449
|
};
|
|
1428
1450
|
}
|
|
1429
1451
|
/**
|
|
1452
|
+
* The deferred reject/close/abort change-emit protocol (issue #49 contract)
|
|
1453
|
+
* shared by `EditSession.onChange`, `TransformSession.onChange`, and
|
|
1454
|
+
* `GraphicEditSession.onChange`. Owns the listener `Set` (like
|
|
1455
|
+
* `createCommitEmitter.register`) and, per emission:
|
|
1456
|
+
*
|
|
1457
|
+
* - builds one event by spreading the controller-supplied `payload` plus the
|
|
1458
|
+
* injected `reject()` / `close()` / `abort()` recorders, and delivers that
|
|
1459
|
+
* same instance to every listener in registration order;
|
|
1460
|
+
* - collects listener exceptions (single → rethrow as-is, multiple →
|
|
1461
|
+
* `AggregateError` with `listenerErrorsMessage`);
|
|
1462
|
+
* - applies decisions after all listeners run, priority `abort` > `close` >
|
|
1463
|
+
* `reject`, with `reject` further gated on `!isSettled()` and
|
|
1464
|
+
* `stillApplicable()`;
|
|
1465
|
+
* - runs `onAccepted` (when the change survived) *before* rethrowing listener
|
|
1466
|
+
* errors — unifying every controller on edit's history-before-throw ordering
|
|
1467
|
+
* — and returns whether the change was accepted.
|
|
1468
|
+
*
|
|
1469
|
+
* Registration-time `isSettled()` guards stay in the controllers' `onChange`
|
|
1470
|
+
* wrappers, mirroring `createCommitEmitter`.
|
|
1471
|
+
*/
|
|
1472
|
+
function createChangeEmitter(options) {
|
|
1473
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1474
|
+
return {
|
|
1475
|
+
register(listener) {
|
|
1476
|
+
listeners.add(listener);
|
|
1477
|
+
return () => {
|
|
1478
|
+
listeners.delete(listener);
|
|
1479
|
+
};
|
|
1480
|
+
},
|
|
1481
|
+
emit(payload, emission) {
|
|
1482
|
+
if (options.isSettled()) return false;
|
|
1483
|
+
let rejectRequested = false;
|
|
1484
|
+
let closeRequested = false;
|
|
1485
|
+
let abortRequested = false;
|
|
1486
|
+
const event = {
|
|
1487
|
+
...payload,
|
|
1488
|
+
reject() {
|
|
1489
|
+
rejectRequested = true;
|
|
1490
|
+
},
|
|
1491
|
+
close() {
|
|
1492
|
+
closeRequested = true;
|
|
1493
|
+
},
|
|
1494
|
+
abort() {
|
|
1495
|
+
abortRequested = true;
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
const errors = [];
|
|
1499
|
+
for (const listener of [...listeners]) try {
|
|
1500
|
+
listener(event);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
errors.push(err);
|
|
1503
|
+
}
|
|
1504
|
+
const applicable = emission.stillApplicable?.() ?? true;
|
|
1505
|
+
if (abortRequested) options.abort();
|
|
1506
|
+
else if (closeRequested) options.close();
|
|
1507
|
+
else if (rejectRequested && !options.isSettled() && applicable) emission.rollback();
|
|
1508
|
+
const accepted = !options.isSettled() && !rejectRequested && applicable;
|
|
1509
|
+
if (accepted) emission.onAccepted?.();
|
|
1510
|
+
if (errors.length === 1) throw errors[0];
|
|
1511
|
+
if (errors.length > 1) throw new AggregateError(errors, options.listenerErrorsMessage);
|
|
1512
|
+
return accepted;
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1430
1517
|
* Subscribe/emit machinery shared by `TransformSession.onCommit`,
|
|
1431
1518
|
* `EditSession.onCommit`, and `DrawSession.onCommit`: a listener set with
|
|
1432
1519
|
* synchronous, exception-collecting emission (single rethrow, `AggregateError`
|
|
@@ -1734,33 +1821,107 @@ function outlinePreview(points) {
|
|
|
1734
1821
|
}
|
|
1735
1822
|
return features;
|
|
1736
1823
|
}
|
|
1824
|
+
/** Whether `b` lands within {@link CLICK_SLOP_PX} of `a` — a click, not a drag. */
|
|
1825
|
+
function withinClickSlop(a, b) {
|
|
1826
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= 3;
|
|
1827
|
+
}
|
|
1828
|
+
function runEditInteraction(spec) {
|
|
1829
|
+
const { adapter, signal, layers, graphicsLayer, previewLayer, handlesLayer, closeOnClickAway } = spec;
|
|
1830
|
+
let ctx;
|
|
1831
|
+
let driver;
|
|
1832
|
+
let previewPick;
|
|
1833
|
+
let handlesPick;
|
|
1834
|
+
let graphicsPick;
|
|
1835
|
+
/**
|
|
1836
|
+
* Consume exactly one trailing click without closing — set through
|
|
1837
|
+
* `shell.suppressNextClickAway()` on delete / label-drag / post-drag paths.
|
|
1838
|
+
*/
|
|
1839
|
+
let suppressClickAway = false;
|
|
1840
|
+
/** A zero-movement body drag released this pointer cycle — set through `shell.noteBodyClick()`. */
|
|
1841
|
+
let bodyClickPending = false;
|
|
1842
|
+
const shell = {
|
|
1843
|
+
suppressNextClickAway() {
|
|
1844
|
+
suppressClickAway = true;
|
|
1845
|
+
},
|
|
1846
|
+
noteBodyClick() {
|
|
1847
|
+
bodyClickPending = true;
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
const onPreviewPick = (event) => {
|
|
1851
|
+
previewPick = event;
|
|
1852
|
+
};
|
|
1853
|
+
const onHandlesPick = (event) => {
|
|
1854
|
+
handlesPick = event;
|
|
1855
|
+
};
|
|
1856
|
+
const onGraphicsPick = (event) => {
|
|
1857
|
+
graphicsPick = event;
|
|
1858
|
+
};
|
|
1859
|
+
const onClick = () => {
|
|
1860
|
+
const localPreview = previewPick;
|
|
1861
|
+
const localHandles = handlesPick;
|
|
1862
|
+
const localGraphics = graphicsPick;
|
|
1863
|
+
const wasBodyClick = bodyClickPending;
|
|
1864
|
+
previewPick = void 0;
|
|
1865
|
+
handlesPick = void 0;
|
|
1866
|
+
graphicsPick = void 0;
|
|
1867
|
+
bodyClickPending = false;
|
|
1868
|
+
if (ctx.settled) return;
|
|
1869
|
+
if (!closeOnClickAway) return;
|
|
1870
|
+
if (suppressClickAway) {
|
|
1871
|
+
suppressClickAway = false;
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
if (localHandles) return;
|
|
1875
|
+
if (localGraphics && !spec.ownsGraphic(localGraphics.id)) {
|
|
1876
|
+
spec.close();
|
|
1877
|
+
spec.onForeignPick?.(localGraphics);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (localPreview) return;
|
|
1881
|
+
if (wasBodyClick) return;
|
|
1882
|
+
spec.close();
|
|
1883
|
+
};
|
|
1884
|
+
return runInteraction({
|
|
1885
|
+
adapter,
|
|
1886
|
+
signal,
|
|
1887
|
+
layers,
|
|
1888
|
+
onViewChange: spec.onViewChange,
|
|
1889
|
+
onSettled: (settlement) => {
|
|
1890
|
+
try {
|
|
1891
|
+
spec.historySettle();
|
|
1892
|
+
} finally {
|
|
1893
|
+
spec.facadeOnSettled?.();
|
|
1894
|
+
spec.emitSettle(settlement);
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
attach(interaction) {
|
|
1898
|
+
ctx = interaction;
|
|
1899
|
+
driver = adapter.createEditPointerDriver();
|
|
1900
|
+
const unsubDown = driver.onPointerDown(spec.onDown);
|
|
1901
|
+
const unsubMove = driver.onPointerMove(spec.onMove);
|
|
1902
|
+
const unsubUp = driver.onPointerUp(spec.onUp);
|
|
1903
|
+
const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
|
|
1904
|
+
const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
|
|
1905
|
+
const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
|
|
1906
|
+
adapter.on("click", onClick);
|
|
1907
|
+
interaction.onTeardown(() => {
|
|
1908
|
+
adapter.off("click", onClick);
|
|
1909
|
+
unsubPreview();
|
|
1910
|
+
unsubHandles();
|
|
1911
|
+
unsubGraphics();
|
|
1912
|
+
unsubDown();
|
|
1913
|
+
unsubMove();
|
|
1914
|
+
unsubUp();
|
|
1915
|
+
spec.extraTeardown?.();
|
|
1916
|
+
driver.dispose();
|
|
1917
|
+
}, spec.onAbort);
|
|
1918
|
+
spec.attach(interaction, driver, shell);
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1737
1922
|
//#endregion
|
|
1738
1923
|
//#region src/tactical-draw/handles.ts
|
|
1739
1924
|
/**
|
|
1740
|
-
* Axis-aligned bounding-box center of the control points — the midpoint of
|
|
1741
|
-
* `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
|
|
1742
|
-
* `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
|
|
1743
|
-
* the first paint), matching mainstream drawing tools' "turn about the visual
|
|
1744
|
-
* middle" convention. Returns `null` for an empty list.
|
|
1745
|
-
*
|
|
1746
|
-
* Exported because the rotation pivot is *not* a handle — the edit controller
|
|
1747
|
-
* consults it directly. See ADR-0016/ADR-0017.
|
|
1748
|
-
*/
|
|
1749
|
-
function computeBoundingBoxCenter(controlPoints) {
|
|
1750
|
-
if (controlPoints.length === 0) return null;
|
|
1751
|
-
let minX = Infinity;
|
|
1752
|
-
let minY = Infinity;
|
|
1753
|
-
let maxX = -Infinity;
|
|
1754
|
-
let maxY = -Infinity;
|
|
1755
|
-
for (const p of controlPoints) {
|
|
1756
|
-
if (p[0] < minX) minX = p[0];
|
|
1757
|
-
if (p[0] > maxX) maxX = p[0];
|
|
1758
|
-
if (p[1] < minY) minY = p[1];
|
|
1759
|
-
if (p[1] > maxY) maxY = p[1];
|
|
1760
|
-
}
|
|
1761
|
-
return [(minX + maxX) / 2, (minY + maxY) / 2];
|
|
1762
|
-
}
|
|
1763
|
-
/**
|
|
1764
1925
|
* Midpoint of the segment between two positions, in lonLat. The single source
|
|
1765
1926
|
* for "where a midpoint handle sits": `buildHandleSet` places the handle here
|
|
1766
1927
|
* and the handle-drag FSM (ADR-0017) inserts the new vertex here, so a grabbed
|
|
@@ -2032,8 +2193,7 @@ function pixelDistance(a, b) {
|
|
|
2032
2193
|
* a lon/lat rotation when the pivot (or a point) cannot be projected. Extra
|
|
2033
2194
|
* coordinate dimensions (z, m) are carried through untouched.
|
|
2034
2195
|
*
|
|
2035
|
-
*
|
|
2036
|
-
* rotate by angle without driving the pointer FSM, and so re-uses this math.
|
|
2196
|
+
* Module-internal: the box-center rotate transition below is the only caller.
|
|
2037
2197
|
*/
|
|
2038
2198
|
function rotatePointsAround(points, pivot, pivotPixel, angle, projection) {
|
|
2039
2199
|
const cos = Math.cos(angle);
|
|
@@ -2191,49 +2351,6 @@ function beginBoxDrag(args) {
|
|
|
2191
2351
|
};
|
|
2192
2352
|
}
|
|
2193
2353
|
//#endregion
|
|
2194
|
-
//#region src/tactical-draw/edit-session.ts
|
|
2195
|
-
/**
|
|
2196
|
-
* Internal test seam — call to simulate a completed gesture against an active
|
|
2197
|
-
* `EditSession`. Public drag plumbing lands in later slices; the contract
|
|
2198
|
-
* tested here is the listener-side emission contract from issue #49.
|
|
2199
|
-
*
|
|
2200
|
-
* @internal
|
|
2201
|
-
*/
|
|
2202
|
-
const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
|
|
2203
|
-
/**
|
|
2204
|
-
* Internal test seam — simulate a completed translate gesture by passing a
|
|
2205
|
-
* `[dx, dy]` delta. Every control point shifts by the delta; one
|
|
2206
|
-
* `EditChangeEvent` is emitted, matching the issue #50 emission contract.
|
|
2207
|
-
*
|
|
2208
|
-
* @internal
|
|
2209
|
-
*/
|
|
2210
|
-
const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
|
|
2211
|
-
/**
|
|
2212
|
-
* Internal test seam — simulate a completed rotate gesture by passing an angle
|
|
2213
|
-
* in radians. Every control point rotates around the transform box's center
|
|
2214
|
-
* (falling back to the control-points' bounding-box center when no box has
|
|
2215
|
-
* been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
|
|
2216
|
-
* the same delta a live rotate box-drag would (ADR-0017).
|
|
2217
|
-
*
|
|
2218
|
-
* @internal
|
|
2219
|
-
*/
|
|
2220
|
-
const COMMIT_ROTATE_FOR_TEST = Symbol("EditSession.commitRotateForTest");
|
|
2221
|
-
/**
|
|
2222
|
-
* Internal test seam — mark a drag as in-flight. Used to verify that
|
|
2223
|
-
* `setModes()` calls are queued until the drag ends. Public pointer plumbing
|
|
2224
|
-
* lives in later slices.
|
|
2225
|
-
*
|
|
2226
|
-
* @internal
|
|
2227
|
-
*/
|
|
2228
|
-
const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
|
|
2229
|
-
/**
|
|
2230
|
-
* Internal test seam — clear the in-flight drag flag without committing. Any
|
|
2231
|
-
* queued `setModes()` call is applied.
|
|
2232
|
-
*
|
|
2233
|
-
* @internal
|
|
2234
|
-
*/
|
|
2235
|
-
const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
2236
|
-
//#endregion
|
|
2237
2354
|
//#region src/tactical-draw/session-history.ts
|
|
2238
2355
|
/** @internal Controller-owned history stack. */
|
|
2239
2356
|
function createSessionHistory(options) {
|
|
@@ -2385,9 +2502,8 @@ function boxToHandles(box, fromPixel) {
|
|
|
2385
2502
|
/**
|
|
2386
2503
|
* The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash so
|
|
2387
2504
|
* the group reads as dashed per the reference design — unless the host
|
|
2388
|
-
* explicitly set their own `strokeDash`, which wins.
|
|
2389
|
-
* `transform-controller.ts`
|
|
2390
|
-
* group boxes can't drift apart (member outlines stay solid).
|
|
2505
|
+
* explicitly set their own `strokeDash`, which wins. Used by
|
|
2506
|
+
* `transform-controller.ts` for every group box (member outlines stay solid).
|
|
2391
2507
|
*/
|
|
2392
2508
|
function groupOutlineStyle(base) {
|
|
2393
2509
|
if (base?.strokeDash !== void 0) return base;
|
|
@@ -2400,10 +2516,9 @@ function groupOutlineStyle(base) {
|
|
|
2400
2516
|
* Build the per-member outline features for a group transform — one closed
|
|
2401
2517
|
* `LineString` per `[memberId, box]` pair, id'd
|
|
2402
2518
|
* `${handleIdPrefix}:member-outline:{memberId}` and styled with the plain
|
|
2403
|
-
* (solid) `boxOutline`.
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2406
|
-
* gets the dashed default (`groupOutlineStyle`).
|
|
2519
|
+
* (solid) `boxOutline`. Used by `transform-controller.ts` for every member of a
|
|
2520
|
+
* (possibly mixed) group transform — only the group box gets the dashed default
|
|
2521
|
+
* (`groupOutlineStyle`).
|
|
2407
2522
|
*/
|
|
2408
2523
|
function memberOutlineFeatures(members, fromPixel, handleIdPrefix, style) {
|
|
2409
2524
|
const features = [];
|
|
@@ -2429,51 +2544,12 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
|
2429
2544
|
properties: style ? { style: { ...style } } : {}
|
|
2430
2545
|
};
|
|
2431
2546
|
}
|
|
2432
|
-
//#endregion
|
|
2433
|
-
//#region src/tactical-draw/transform-amplifier-placements.ts
|
|
2434
|
-
/**
|
|
2435
|
-
* Carry user-adjusted label anchors (and rotation overrides) through a
|
|
2436
|
-
* transform-box gesture. Anchors are transformed in pixel space alongside the
|
|
2437
|
-
* control points so the result remains visually rigid across map
|
|
2438
|
-
* projections.
|
|
2439
|
-
*
|
|
2440
|
-
* Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
|
|
2441
|
-
* clockwise on screen (see `transform-box.ts`). Adapters render a label's
|
|
2442
|
-
* on-screen clockwise angle as `π − rotation`, and a rotation delta is
|
|
2443
|
-
* invariant under the map→screen bearing, so an on-screen delta
|
|
2444
|
-
* `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
|
|
2445
|
-
* i.e. `rotation' = rotation − Δ`.
|
|
2446
|
-
*/
|
|
2447
|
-
function transformAmplifierPlacements(placements, from, to, projection) {
|
|
2448
|
-
if (!placements) return void 0;
|
|
2449
|
-
const scaleX = to.halfWidth / from.halfWidth;
|
|
2450
|
-
const scaleY = to.halfHeight / from.halfHeight;
|
|
2451
|
-
const angleDelta = to.angle - from.angle;
|
|
2452
|
-
const transformed = {};
|
|
2453
|
-
for (const [field, raw] of Object.entries(placements)) {
|
|
2454
|
-
const resolved = resolveAmplifierPlacement(raw);
|
|
2455
|
-
const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
|
|
2456
|
-
if (!resolved.position) {
|
|
2457
|
-
if (rotation !== void 0) transformed[field] = { rotation };
|
|
2458
|
-
continue;
|
|
2459
|
-
}
|
|
2460
|
-
const pixel = projection.toPixel(resolved.position);
|
|
2461
|
-
const position = pixel ? (() => {
|
|
2462
|
-
const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
|
|
2463
|
-
const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
|
|
2464
|
-
return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
|
|
2465
|
-
})() : [...resolved.position];
|
|
2466
|
-
transformed[field] = rotation !== void 0 ? {
|
|
2467
|
-
position,
|
|
2468
|
-
rotation
|
|
2469
|
-
} : position;
|
|
2470
|
-
}
|
|
2471
|
-
return transformed;
|
|
2472
|
-
}
|
|
2473
2547
|
/** Estimated glyph width as a fraction of text height — a monospace-ish approximation, not a real text-metrics measurement. */
|
|
2474
2548
|
const CHAR_WIDTH_FACTOR = .6;
|
|
2475
2549
|
/** Estimated line height as a multiple of text height. */
|
|
2476
2550
|
const LINE_HEIGHT_FACTOR = 1.2;
|
|
2551
|
+
/** Screen-space clearance between a label's anchor and its visible drag grip. */
|
|
2552
|
+
const LABEL_HANDLE_OFFSET_PX = 14;
|
|
2477
2553
|
/**
|
|
2478
2554
|
* Normalize `angle` (radians) into `(-π, π]`, matching the range
|
|
2479
2555
|
* `Math.atan2`/screen-angle math elsewhere in `tactical-draw` produces.
|
|
@@ -2559,36 +2635,45 @@ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
|
|
|
2559
2635
|
bearing: view.bearing
|
|
2560
2636
|
});
|
|
2561
2637
|
}
|
|
2562
|
-
//#endregion
|
|
2563
|
-
//#region src/tactical-draw/edit-controller.ts
|
|
2564
|
-
/**
|
|
2565
|
-
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
2566
|
-
* considered equivalent to the edit-start input when every control-point
|
|
2567
|
-
* component agrees within this threshold. PRD #40 calls out tolerance-based
|
|
2568
|
-
* dirty tracking; the constant lives in one place so future slices can lift
|
|
2569
|
-
* it to an option without touching call sites.
|
|
2570
|
-
*/
|
|
2571
|
-
const DIRTY_EPSILON$1 = 1e-9;
|
|
2572
|
-
const HANDLE_HIT_TOLERANCE_PX$1 = 12;
|
|
2573
|
-
/** Screen-space clearance between a label's anchor and its visible drag grip. */
|
|
2574
|
-
const LABEL_HANDLE_OFFSET_PX = 14;
|
|
2575
2638
|
/**
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
2582
|
-
*
|
|
2583
|
-
*
|
|
2639
|
+
* Carry user-adjusted label anchors (and rotation overrides) through a
|
|
2640
|
+
* transform-box gesture. Anchors are transformed in pixel space alongside the
|
|
2641
|
+
* control points so the result remains visually rigid across map
|
|
2642
|
+
* projections.
|
|
2643
|
+
*
|
|
2644
|
+
* Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
|
|
2645
|
+
* clockwise on screen (see `transform-box.ts`). Adapters render a label's
|
|
2646
|
+
* on-screen clockwise angle as `π − rotation`, and a rotation delta is
|
|
2647
|
+
* invariant under the map→screen bearing, so an on-screen delta
|
|
2648
|
+
* `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
|
|
2649
|
+
* i.e. `rotation' = rotation − Δ`.
|
|
2584
2650
|
*/
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
])
|
|
2651
|
+
function transformAmplifierPlacements(placements, from, to, projection) {
|
|
2652
|
+
if (!placements) return void 0;
|
|
2653
|
+
const scaleX = to.halfWidth / from.halfWidth;
|
|
2654
|
+
const scaleY = to.halfHeight / from.halfHeight;
|
|
2655
|
+
const angleDelta = to.angle - from.angle;
|
|
2656
|
+
const transformed = {};
|
|
2657
|
+
for (const [field, raw] of Object.entries(placements)) {
|
|
2658
|
+
const resolved = resolveAmplifierPlacement(raw);
|
|
2659
|
+
const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
|
|
2660
|
+
if (!resolved.position) {
|
|
2661
|
+
if (rotation !== void 0) transformed[field] = { rotation };
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
const pixel = projection.toPixel(resolved.position);
|
|
2665
|
+
const position = pixel ? (() => {
|
|
2666
|
+
const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
|
|
2667
|
+
const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
|
|
2668
|
+
return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
|
|
2669
|
+
})() : [...resolved.position];
|
|
2670
|
+
transformed[field] = rotation !== void 0 ? {
|
|
2671
|
+
position,
|
|
2672
|
+
rotation
|
|
2673
|
+
} : position;
|
|
2674
|
+
}
|
|
2675
|
+
return transformed;
|
|
2676
|
+
}
|
|
2592
2677
|
/**
|
|
2593
2678
|
* Field-wise merge of one `amplifierPlacements` entry: fields present in
|
|
2594
2679
|
* `incoming` (valid position / finite rotation) replace the same field on
|
|
@@ -2609,70 +2694,340 @@ function mergeAmplifierPlacementField(existing, incoming) {
|
|
|
2609
2694
|
} : { rotation: merged.rotation };
|
|
2610
2695
|
}
|
|
2611
2696
|
/**
|
|
2612
|
-
*
|
|
2613
|
-
*
|
|
2614
|
-
*
|
|
2615
|
-
*
|
|
2616
|
-
* - preview + reshape-handle rendering, and the oriented transform box
|
|
2617
|
-
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
2618
|
-
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
2619
|
-
* - default click-away close policy
|
|
2620
|
-
* - close / abort / signal / preempt / destroy paths
|
|
2697
|
+
* The stateful label-interaction object owned by the edit controller's
|
|
2698
|
+
* `labeldrag` mode (ADR-0034). Holds the selection, the fitted selection box,
|
|
2699
|
+
* and the two live drag states; returns intents for the controller to apply to
|
|
2700
|
+
* its session-owned `workingAmplifierPlacements`.
|
|
2621
2701
|
*/
|
|
2622
|
-
function
|
|
2623
|
-
const {
|
|
2624
|
-
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2625
|
-
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2626
|
-
const metadata = getControlMeasureMetadata(measure.kind);
|
|
2627
|
-
const rule = metadata.rule;
|
|
2628
|
-
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
2629
|
-
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
2630
|
-
const closedRing = rule?.closedRing ?? false;
|
|
2631
|
-
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
2632
|
-
const isAreaKind = metadata.geometry === "area";
|
|
2633
|
-
const requestedModes = normaliseModes(initialModes);
|
|
2634
|
-
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2635
|
-
const workingPoints = editStartPoints.map((p) => [...p]);
|
|
2636
|
-
let workingOptions = { ...measure.options ?? {} };
|
|
2637
|
-
let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
|
|
2638
|
-
let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
|
|
2639
|
-
function snapshotAmplifierPlacements() {
|
|
2640
|
-
return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
|
|
2641
|
-
}
|
|
2642
|
-
let modes = requestedModes;
|
|
2643
|
-
let dirty = false;
|
|
2644
|
-
let nonGeometryDirty = false;
|
|
2645
|
-
let dragInFlight = false;
|
|
2702
|
+
function createLabelPlacement(deps) {
|
|
2703
|
+
const { projection, view, features, snapshotAmplifierPlacements, handleIdPrefix, interactionStyle } = deps;
|
|
2646
2704
|
/**
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
2649
|
-
*
|
|
2705
|
+
* The currently selected label's `labelPlacementKey`, or `null` when no label
|
|
2706
|
+
* is selected. A click on a label toggles this (select / deselect / switch —
|
|
2707
|
+
* see `onUp`'s move-drag click path); it is cleared whenever the feature
|
|
2708
|
+
* disappears from the preview or the mode set drops `labeldrag`.
|
|
2650
2709
|
*/
|
|
2651
|
-
let
|
|
2710
|
+
let selectedLabelKey = null;
|
|
2652
2711
|
/**
|
|
2653
|
-
*
|
|
2654
|
-
*
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
* `handleClick`.
|
|
2712
|
+
* The transform box fitted around the selected label, re-fitted on every
|
|
2713
|
+
* `handleFeatures` call from the preview features — except while a label
|
|
2714
|
+
* rotate drag is in flight, when it is painted rigidly from the drag's own
|
|
2715
|
+
* live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`). `null` when
|
|
2716
|
+
* nothing is selected or the selection couldn't be fitted this frame.
|
|
2659
2717
|
*/
|
|
2660
|
-
let
|
|
2718
|
+
let labelBox = null;
|
|
2661
2719
|
/**
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
2665
|
-
*
|
|
2720
|
+
* A live label *move* drag: direct label grab, the floating grip, or a body
|
|
2721
|
+
* grab inside the selected label's transform box (ADR-0017 box machinery
|
|
2722
|
+
* applied to labels) — all three funnel through this one state, distinguished
|
|
2723
|
+
* only by `grabOffset`. `grabOffset` is the anchor-pixel-minus-down-pixel
|
|
2724
|
+
* delta captured at pointer-down, so `labelAnchorFromPointer` can re-derive
|
|
2725
|
+
* the intended anchor from any subsequent pointer pixel without the label
|
|
2726
|
+
* snapping to the pointer's exact position (a direct grab derives `[0, 0]`;
|
|
2727
|
+
* the floating grip derives `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab
|
|
2728
|
+
* derives whatever offset the click landed at within the box).
|
|
2666
2729
|
*/
|
|
2667
|
-
let
|
|
2730
|
+
let activeAmplifierDrag = null;
|
|
2668
2731
|
/**
|
|
2669
|
-
*
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2732
|
+
* A live label *rotate* drag (ADR-0017 box machinery applied to a label's
|
|
2733
|
+
* rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
|
|
2734
|
+
* `textSizeMeters` are captured at pointer-down so `lastBox` can be
|
|
2735
|
+
* re-fitted purely from drag math on every move tick — independent of
|
|
2736
|
+
* the preview features, which mirrors `activeBoxDrag.lastSnapshot` and
|
|
2737
|
+
* keeps the box painted even if a transient preview render fails.
|
|
2672
2738
|
*/
|
|
2673
|
-
let
|
|
2674
|
-
|
|
2675
|
-
|
|
2739
|
+
let activeLabelRotateDrag = null;
|
|
2740
|
+
function pickLabel(pixel, tolerance) {
|
|
2741
|
+
for (const feature of features()) {
|
|
2742
|
+
const key = feature.properties?.labelPlacementKey;
|
|
2743
|
+
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2744
|
+
const projected = projection.toPixel(feature.geometry.coordinates);
|
|
2745
|
+
if (!projected) continue;
|
|
2746
|
+
for (const handle of [false, true]) {
|
|
2747
|
+
const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
|
|
2748
|
+
if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
|
|
2749
|
+
key,
|
|
2750
|
+
anchorPixel: projected
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
const box = fitLabelBoxFromFeature(feature, projected, view());
|
|
2754
|
+
if (box && pointInBox(box, pixel)) return {
|
|
2755
|
+
key,
|
|
2756
|
+
anchorPixel: projected
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
|
|
2763
|
+
* `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
|
|
2764
|
+
* see `activeAmplifierDrag`), so the label keeps whatever offset it was
|
|
2765
|
+
* grabbed at instead of snapping to the pointer.
|
|
2766
|
+
*/
|
|
2767
|
+
function labelAnchorFromPointer(pixel, grabOffset) {
|
|
2768
|
+
return projection.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* The selected label's rendered feature + its current anchor pixel, or
|
|
2772
|
+
* `null` when nothing is selected, the feature is gone from this frame's
|
|
2773
|
+
* preview, or its anchor fails to project. The single lookup shared by the
|
|
2774
|
+
* box fit (`handleFeatures`) and the box-pick paths (`onDown`).
|
|
2775
|
+
*/
|
|
2776
|
+
function selectedLabelAnchor() {
|
|
2777
|
+
if (!selectedLabelKey) return null;
|
|
2778
|
+
const feature = features().find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
|
|
2779
|
+
if (!feature || feature.geometry?.type !== "Point") return null;
|
|
2780
|
+
const pixel = projection.toPixel(feature.geometry.coordinates);
|
|
2781
|
+
if (!pixel) return null;
|
|
2782
|
+
return {
|
|
2783
|
+
feature,
|
|
2784
|
+
pixel
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
/** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
|
|
2788
|
+
function pointerAngleAround(pixel, anchor) {
|
|
2789
|
+
return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
2790
|
+
}
|
|
2791
|
+
function handleFeatures() {
|
|
2792
|
+
const style = interactionStyle?.labelHandle;
|
|
2793
|
+
const features_ = [];
|
|
2794
|
+
let index = 0;
|
|
2795
|
+
labelBox = null;
|
|
2796
|
+
if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
|
|
2797
|
+
else {
|
|
2798
|
+
const anchor = selectedLabelAnchor();
|
|
2799
|
+
if (selectedLabelKey && !anchor) selectedLabelKey = null;
|
|
2800
|
+
else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel, view());
|
|
2801
|
+
}
|
|
2802
|
+
for (const feature of features()) {
|
|
2803
|
+
const key = feature.properties?.labelPlacementKey;
|
|
2804
|
+
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2805
|
+
if (key === selectedLabelKey) continue;
|
|
2806
|
+
const pixel = projection.toPixel(feature.geometry.coordinates);
|
|
2807
|
+
if (!pixel) continue;
|
|
2808
|
+
features_.push({
|
|
2809
|
+
type: "Feature",
|
|
2810
|
+
id: `${handleIdPrefix}:label-handle:${index}`,
|
|
2811
|
+
geometry: {
|
|
2812
|
+
type: "Point",
|
|
2813
|
+
coordinates: projection.fromPixel([pixel[0], pixel[1] - LABEL_HANDLE_OFFSET_PX])
|
|
2814
|
+
},
|
|
2815
|
+
properties: {
|
|
2816
|
+
handle: "label",
|
|
2817
|
+
index,
|
|
2818
|
+
labelPlacementKey: key,
|
|
2819
|
+
...style ? { style: { ...style } } : {}
|
|
2820
|
+
}
|
|
2821
|
+
});
|
|
2822
|
+
index += 1;
|
|
2823
|
+
}
|
|
2824
|
+
if (labelBox) {
|
|
2825
|
+
features_.push(boxOutlineFeature(labelBox, projection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
|
|
2826
|
+
const rotateHandle = {
|
|
2827
|
+
kind: "rotate",
|
|
2828
|
+
index: 0,
|
|
2829
|
+
position: projection.fromPixel(rotateGripPixel(labelBox, 14))
|
|
2830
|
+
};
|
|
2831
|
+
features_.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
|
|
2832
|
+
}
|
|
2833
|
+
return features_;
|
|
2834
|
+
}
|
|
2835
|
+
function onDown(pixel, tolerance) {
|
|
2836
|
+
if (labelBox) {
|
|
2837
|
+
const anchor = selectedLabelAnchor();
|
|
2838
|
+
if (anchor) {
|
|
2839
|
+
const gripPixel = rotateGripPixel(labelBox, 14);
|
|
2840
|
+
if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tolerance) {
|
|
2841
|
+
const props = anchor.feature.properties ?? {};
|
|
2842
|
+
activeLabelRotateDrag = {
|
|
2843
|
+
key: selectedLabelKey,
|
|
2844
|
+
anchorPixel: anchor.pixel,
|
|
2845
|
+
startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
|
|
2846
|
+
startRotation: typeof props.rotation === "number" ? props.rotation : 0,
|
|
2847
|
+
previous: snapshotAmplifierPlacements(),
|
|
2848
|
+
downPixel: pixel,
|
|
2849
|
+
text: typeof props.text === "string" ? props.text : void 0,
|
|
2850
|
+
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
2851
|
+
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
2852
|
+
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
2853
|
+
lastBox: labelBox
|
|
2854
|
+
};
|
|
2855
|
+
return true;
|
|
2856
|
+
}
|
|
2857
|
+
if (pointInBox(labelBox, pixel)) {
|
|
2858
|
+
activeAmplifierDrag = {
|
|
2859
|
+
field: selectedLabelKey,
|
|
2860
|
+
grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
|
|
2861
|
+
previous: snapshotAmplifierPlacements(),
|
|
2862
|
+
downPixel: pixel
|
|
2863
|
+
};
|
|
2864
|
+
return true;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
const labelHit = pickLabel(pixel, tolerance);
|
|
2869
|
+
if (labelHit) {
|
|
2870
|
+
activeAmplifierDrag = {
|
|
2871
|
+
field: labelHit.key,
|
|
2872
|
+
grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
|
|
2873
|
+
previous: snapshotAmplifierPlacements(),
|
|
2874
|
+
downPixel: pixel
|
|
2875
|
+
};
|
|
2876
|
+
return true;
|
|
2877
|
+
}
|
|
2878
|
+
return false;
|
|
2879
|
+
}
|
|
2880
|
+
function onMove(pixel) {
|
|
2881
|
+
if (activeLabelRotateDrag) {
|
|
2882
|
+
const drag = activeLabelRotateDrag;
|
|
2883
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
2884
|
+
const rotation = drag.startRotation - delta;
|
|
2885
|
+
const { resolution, bearing } = view();
|
|
2886
|
+
const liveBox = fitLabelTransformBox({
|
|
2887
|
+
text: drag.text,
|
|
2888
|
+
rotation,
|
|
2889
|
+
textAnchor: drag.textAnchor,
|
|
2890
|
+
textSizePixels: drag.textSizePixels,
|
|
2891
|
+
textSizeMeters: drag.textSizeMeters,
|
|
2892
|
+
anchor: drag.anchorPixel,
|
|
2893
|
+
resolution,
|
|
2894
|
+
bearing
|
|
2895
|
+
});
|
|
2896
|
+
if (liveBox) drag.lastBox = liveBox;
|
|
2897
|
+
return {
|
|
2898
|
+
field: drag.key,
|
|
2899
|
+
value: { rotation }
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
if (activeAmplifierDrag) {
|
|
2903
|
+
const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
|
|
2904
|
+
if (!position) return null;
|
|
2905
|
+
return {
|
|
2906
|
+
field: activeAmplifierDrag.field,
|
|
2907
|
+
value: position
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
return null;
|
|
2911
|
+
}
|
|
2912
|
+
function onUp(pixel) {
|
|
2913
|
+
if (activeLabelRotateDrag) {
|
|
2914
|
+
const drag = activeLabelRotateDrag;
|
|
2915
|
+
activeLabelRotateDrag = null;
|
|
2916
|
+
if (!withinClickSlop(pixel, drag.downPixel)) {
|
|
2917
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
2918
|
+
const rotation = drag.startRotation - delta;
|
|
2919
|
+
return {
|
|
2920
|
+
kind: "rotate",
|
|
2921
|
+
key: drag.key,
|
|
2922
|
+
rotation,
|
|
2923
|
+
previous: drag.previous
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
return {
|
|
2927
|
+
kind: "click",
|
|
2928
|
+
previous: drag.previous
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
if (activeAmplifierDrag) {
|
|
2932
|
+
const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
|
|
2933
|
+
activeAmplifierDrag = null;
|
|
2934
|
+
const position = labelAnchorFromPointer(pixel, grabOffset);
|
|
2935
|
+
if (position && !withinClickSlop(pixel, downPixel)) return {
|
|
2936
|
+
kind: "move",
|
|
2937
|
+
field,
|
|
2938
|
+
position,
|
|
2939
|
+
previous
|
|
2940
|
+
};
|
|
2941
|
+
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
2942
|
+
return {
|
|
2943
|
+
kind: "click",
|
|
2944
|
+
previous
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
2949
|
+
return {
|
|
2950
|
+
handleFeatures,
|
|
2951
|
+
onDown,
|
|
2952
|
+
onMove,
|
|
2953
|
+
onUp,
|
|
2954
|
+
get dragging() {
|
|
2955
|
+
return activeAmplifierDrag !== null || activeLabelRotateDrag !== null;
|
|
2956
|
+
},
|
|
2957
|
+
endDrag() {
|
|
2958
|
+
activeAmplifierDrag = null;
|
|
2959
|
+
activeLabelRotateDrag = null;
|
|
2960
|
+
},
|
|
2961
|
+
syncModes(modes) {
|
|
2962
|
+
if (!modes.includes("labeldrag")) selectedLabelKey = null;
|
|
2963
|
+
}
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
//#endregion
|
|
2967
|
+
//#region src/tactical-draw/edit-controller.ts
|
|
2968
|
+
const ALLOWED_EDIT_MODES = new Set([
|
|
2969
|
+
"reshape",
|
|
2970
|
+
"transform",
|
|
2971
|
+
"labeldrag",
|
|
2972
|
+
"delete"
|
|
2973
|
+
]);
|
|
2974
|
+
/**
|
|
2975
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
2976
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
2977
|
+
*
|
|
2978
|
+
* - lift-original (remove from graphics layer) + working copy
|
|
2979
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
2980
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
2981
|
+
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
2982
|
+
* - default click-away close policy
|
|
2983
|
+
* - close / abort / signal / preempt / destroy paths
|
|
2984
|
+
*/
|
|
2985
|
+
function runEdit(opts) {
|
|
2986
|
+
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
|
|
2987
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2988
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2989
|
+
const metadata = getControlMeasureMetadata(measure.kind);
|
|
2990
|
+
const rule = metadata.rule;
|
|
2991
|
+
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
2992
|
+
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
2993
|
+
const closedRing = rule?.closedRing ?? false;
|
|
2994
|
+
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
2995
|
+
const isAreaKind = metadata.geometry === "area";
|
|
2996
|
+
const requestedModes = normaliseModes(initialModes);
|
|
2997
|
+
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2998
|
+
const workingPoints = editStartPoints.map((p) => [...p]);
|
|
2999
|
+
let workingOptions = { ...measure.options ?? {} };
|
|
3000
|
+
let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
|
|
3001
|
+
let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
|
|
3002
|
+
function snapshotAmplifierPlacements() {
|
|
3003
|
+
return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
|
|
3004
|
+
}
|
|
3005
|
+
let modes = requestedModes;
|
|
3006
|
+
let dirty = false;
|
|
3007
|
+
let nonGeometryDirty = false;
|
|
3008
|
+
let dragInFlight = false;
|
|
3009
|
+
/**
|
|
3010
|
+
* Mutable style snapshot. Initialised from the edit-start input; replaced
|
|
3011
|
+
* when the façade routes an external style change to us via
|
|
3012
|
+
* `notifyExternalMeasure`. `EditSession.measure` stays the immutable
|
|
3013
|
+
* edit-start input per PRD #40 user-story 30.
|
|
3014
|
+
*/
|
|
3015
|
+
let currentStyle = measure.style;
|
|
3016
|
+
/**
|
|
3017
|
+
* `setModes()` called during an in-flight drag is queued here and drained
|
|
3018
|
+
* when the gesture ends — handles must not disappear under the pointer
|
|
3019
|
+
* (PRD #40 user-story-21).
|
|
3020
|
+
*/
|
|
3021
|
+
let pendingModes = null;
|
|
3022
|
+
const changeEmitter = createChangeEmitter({
|
|
3023
|
+
listenerErrorsMessage: "EditSession.onChange listener errors",
|
|
3024
|
+
isSettled: () => ctx.settled,
|
|
3025
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
3026
|
+
close: () => {
|
|
3027
|
+
ctx.commit(rebuildRendered);
|
|
3028
|
+
}
|
|
3029
|
+
});
|
|
3030
|
+
const commitEmitter = createCommitEmitter("EditSession.onCommit listener errors");
|
|
2676
3031
|
const settleEmitter = createSettleEmitter("EditSession.onSettled listener errors");
|
|
2677
3032
|
/**
|
|
2678
3033
|
* The transform box's rotation, in radians, session-scoped (ADR-0017 decision
|
|
@@ -2713,28 +3068,24 @@ function runEdit(opts) {
|
|
|
2713
3068
|
let box = null;
|
|
2714
3069
|
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
2715
3070
|
let lastPreviewFeatures = [];
|
|
2716
|
-
/**
|
|
2717
|
-
* The currently selected label's `labelPlacementKey` in `labeldrag` mode, or
|
|
2718
|
-
* `null` when no label is selected. A click on a label toggles this (select
|
|
2719
|
-
* / deselect / switch — see `onDriverUp`'s amplifier-drag click path); it is
|
|
2720
|
-
* cleared whenever the feature disappears from `lastPreviewFeatures` or the
|
|
2721
|
-
* mode set drops `labeldrag` (see `applyModes`).
|
|
2722
|
-
*/
|
|
2723
|
-
let selectedLabelKey = null;
|
|
2724
|
-
/**
|
|
2725
|
-
* The transform box fitted around the selected label (`label-transform-box.ts`),
|
|
2726
|
-
* re-fitted on every `renderHandlesLayer` call from `lastPreviewFeatures` —
|
|
2727
|
-
* except while a label rotate drag is in flight, when it is painted rigidly
|
|
2728
|
-
* from the drag's own live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`).
|
|
2729
|
-
* `null` when nothing is selected or the selection couldn't be fitted this frame.
|
|
2730
|
-
*/
|
|
2731
|
-
let labelBox = null;
|
|
2732
3071
|
let ctx;
|
|
2733
3072
|
let driver;
|
|
3073
|
+
let shell;
|
|
2734
3074
|
const dragProjection = {
|
|
2735
3075
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2736
3076
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2737
3077
|
};
|
|
3078
|
+
const labels = createLabelPlacement({
|
|
3079
|
+
projection: dragProjection,
|
|
3080
|
+
view: () => ({
|
|
3081
|
+
resolution: adapter.getResolution(),
|
|
3082
|
+
bearing: adapter.getBearing()
|
|
3083
|
+
}),
|
|
3084
|
+
features: () => lastPreviewFeatures,
|
|
3085
|
+
snapshotAmplifierPlacements,
|
|
3086
|
+
handleIdPrefix,
|
|
3087
|
+
interactionStyle
|
|
3088
|
+
});
|
|
2738
3089
|
/**
|
|
2739
3090
|
* Fit the transform box around `features`' rendered geometry: every
|
|
2740
3091
|
* coordinate is projected to pixels via the pointer driver and
|
|
@@ -2807,51 +3158,7 @@ function runEdit(opts) {
|
|
|
2807
3158
|
trailingFixedSlots,
|
|
2808
3159
|
closedRing
|
|
2809
3160
|
}), handleIdPrefix, interactionStyle);
|
|
2810
|
-
if (modes.includes("labeldrag"))
|
|
2811
|
-
const style = interactionStyle?.labelHandle;
|
|
2812
|
-
let index = 0;
|
|
2813
|
-
labelBox = null;
|
|
2814
|
-
if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
|
|
2815
|
-
else {
|
|
2816
|
-
const anchor = selectedLabelAnchor();
|
|
2817
|
-
if (selectedLabelKey && !anchor) selectedLabelKey = null;
|
|
2818
|
-
else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel, {
|
|
2819
|
-
resolution: adapter.getResolution(),
|
|
2820
|
-
bearing: adapter.getBearing()
|
|
2821
|
-
});
|
|
2822
|
-
}
|
|
2823
|
-
for (const feature of lastPreviewFeatures) {
|
|
2824
|
-
const key = feature.properties?.labelPlacementKey;
|
|
2825
|
-
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2826
|
-
if (key === selectedLabelKey) continue;
|
|
2827
|
-
const pixel = driver.toPixel(feature.geometry.coordinates);
|
|
2828
|
-
if (!pixel) continue;
|
|
2829
|
-
features.push({
|
|
2830
|
-
type: "Feature",
|
|
2831
|
-
id: `${handleIdPrefix}:label-handle:${index}`,
|
|
2832
|
-
geometry: {
|
|
2833
|
-
type: "Point",
|
|
2834
|
-
coordinates: driver.fromPixel([pixel[0], pixel[1] - LABEL_HANDLE_OFFSET_PX])
|
|
2835
|
-
},
|
|
2836
|
-
properties: {
|
|
2837
|
-
handle: "label",
|
|
2838
|
-
index,
|
|
2839
|
-
labelPlacementKey: key,
|
|
2840
|
-
...style ? { style: { ...style } } : {}
|
|
2841
|
-
}
|
|
2842
|
-
});
|
|
2843
|
-
index += 1;
|
|
2844
|
-
}
|
|
2845
|
-
if (labelBox) {
|
|
2846
|
-
features.push(boxOutlineFeature(labelBox, dragProjection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
|
|
2847
|
-
const rotateHandle = {
|
|
2848
|
-
kind: "rotate",
|
|
2849
|
-
index: 0,
|
|
2850
|
-
position: dragProjection.fromPixel(rotateGripPixel(labelBox, 14))
|
|
2851
|
-
};
|
|
2852
|
-
features.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
|
|
2853
|
-
}
|
|
2854
|
-
}
|
|
3161
|
+
if (modes.includes("labeldrag")) features.push(...labels.handleFeatures());
|
|
2855
3162
|
if (modes.includes("transform")) {
|
|
2856
3163
|
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
2857
3164
|
if (liveBox) {
|
|
@@ -2888,7 +3195,7 @@ function runEdit(opts) {
|
|
|
2888
3195
|
return rebuildRenderedWith(workingOptions);
|
|
2889
3196
|
}
|
|
2890
3197
|
function recomputeDirty() {
|
|
2891
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints,
|
|
3198
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, 1e-9);
|
|
2892
3199
|
}
|
|
2893
3200
|
function replaceWorkingPoints(next) {
|
|
2894
3201
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -2914,65 +3221,37 @@ function runEdit(opts) {
|
|
|
2914
3221
|
};
|
|
2915
3222
|
}
|
|
2916
3223
|
/**
|
|
2917
|
-
* Emit a completed gesture
|
|
2918
|
-
* snapshot is provided as
|
|
2919
|
-
*
|
|
2920
|
-
* this is called.
|
|
2921
|
-
*
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
2924
|
-
*
|
|
2925
|
-
* applied after every listener has run (and after exceptions are
|
|
2926
|
-
* collected). `close`/`abort` override `reject` when both are called.
|
|
2927
|
-
* - Listener exceptions are collected and rethrown after all listeners
|
|
2928
|
-
* run (single → as-is, multiple → AggregateError).
|
|
2929
|
-
* - Reject restores the working state to the pre-gesture snapshot and
|
|
2930
|
-
* recomputes `dirty`. Close/abort settle the session as normal.
|
|
3224
|
+
* Emit a completed gesture through the shared change-emit protocol
|
|
3225
|
+
* (`createChangeEmitter`, issue #49). The pre-gesture snapshot is provided as
|
|
3226
|
+
* `previousRendered`; `commitGesture` has already mutated `workingPoints` and
|
|
3227
|
+
* re-rendered preview / handle layers before this is called. Only this
|
|
3228
|
+
* controller's variance stays here: the event payload, the reject rollback
|
|
3229
|
+
* (restore the pre-gesture working state), and the recordable-source guard on
|
|
3230
|
+
* the history record. The deferred reject/close/abort machinery, exception
|
|
3231
|
+
* collection, and record-before-rethrow ordering live in the emitter.
|
|
2931
3232
|
*/
|
|
2932
3233
|
function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source = "gesture", historyLabel = "Edit", previousBoxAngle = boxAngle) {
|
|
2933
|
-
|
|
2934
|
-
let rejectRequested = false;
|
|
2935
|
-
let closeRequested = false;
|
|
2936
|
-
let abortRequested = false;
|
|
2937
|
-
const event = {
|
|
3234
|
+
changeEmitter.emit({
|
|
2938
3235
|
source,
|
|
2939
3236
|
graphic: currentRendered,
|
|
2940
3237
|
previous: previousRendered,
|
|
2941
|
-
session
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
3238
|
+
session
|
|
3239
|
+
}, {
|
|
3240
|
+
rollback() {
|
|
3241
|
+
replaceWorkingPoints(previousPoints);
|
|
3242
|
+
replaceWorkingOptions(previousOptions);
|
|
3243
|
+
currentStyle = previousStyle;
|
|
3244
|
+
sizeAnchor = previousSizeAnchor;
|
|
3245
|
+
workingTextAmplifiers = previousTextAmplifiers;
|
|
3246
|
+
workingAmplifierPlacements = previousAmplifierPlacements;
|
|
3247
|
+
boxAngle = previousBoxAngle;
|
|
3248
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
3249
|
+
renderPreview();
|
|
3250
|
+
renderHandlesLayer();
|
|
3251
|
+
recomputeDirty();
|
|
2947
3252
|
},
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
}
|
|
2951
|
-
};
|
|
2952
|
-
const errors = [];
|
|
2953
|
-
for (const listener of [...changeListeners]) try {
|
|
2954
|
-
listener(event);
|
|
2955
|
-
} catch (err) {
|
|
2956
|
-
errors.push(err);
|
|
2957
|
-
}
|
|
2958
|
-
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
2959
|
-
else if (closeRequested) ctx.commit(rebuildRendered);
|
|
2960
|
-
else if (rejectRequested && !ctx.settled) {
|
|
2961
|
-
replaceWorkingPoints(previousPoints);
|
|
2962
|
-
replaceWorkingOptions(previousOptions);
|
|
2963
|
-
currentStyle = previousStyle;
|
|
2964
|
-
sizeAnchor = previousSizeAnchor;
|
|
2965
|
-
workingTextAmplifiers = previousTextAmplifiers;
|
|
2966
|
-
workingAmplifierPlacements = previousAmplifierPlacements;
|
|
2967
|
-
boxAngle = previousBoxAngle;
|
|
2968
|
-
nonGeometryDirty = previousNonGeometryDirty;
|
|
2969
|
-
renderPreview();
|
|
2970
|
-
renderHandlesLayer();
|
|
2971
|
-
recomputeDirty();
|
|
2972
|
-
}
|
|
2973
|
-
if (!ctx.settled && !rejectRequested && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
|
|
2974
|
-
if (errors.length === 1) throw errors[0];
|
|
2975
|
-
if (errors.length > 1) throw new AggregateError(errors, "EditSession.onChange listener errors");
|
|
3253
|
+
onAccepted: source === "gesture" || source === "programmatic" ? () => history.record(captureHistory(), historyLabel) : void 0
|
|
3254
|
+
});
|
|
2976
3255
|
}
|
|
2977
3256
|
/**
|
|
2978
3257
|
* Apply a completed gesture: snapshot pre-state, write the new control
|
|
@@ -3161,35 +3440,6 @@ function runEdit(opts) {
|
|
|
3161
3440
|
dirty = true;
|
|
3162
3441
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change size anchor");
|
|
3163
3442
|
}
|
|
3164
|
-
/** Apply a translate gesture by shifting every working point by `delta`. */
|
|
3165
|
-
function commitTranslate(delta) {
|
|
3166
|
-
if (ctx.settled) return;
|
|
3167
|
-
const [dx, dy] = delta;
|
|
3168
|
-
commitGesture(workingPoints.map((p) => {
|
|
3169
|
-
const out = [p[0] + dx, p[1] + dy];
|
|
3170
|
-
for (let i = 2; i < p.length; i++) out.push(p[i]);
|
|
3171
|
-
return out;
|
|
3172
|
-
}));
|
|
3173
|
-
}
|
|
3174
|
-
/**
|
|
3175
|
-
* Test seam for a rotate gesture: rotate every working point by `angle`
|
|
3176
|
-
* (radians) around the transform box's center — falling back to the
|
|
3177
|
-
* control-points' bounding-box center when no box has been fitted yet (e.g.
|
|
3178
|
-
* before the first paint). The rotation runs in screen space (see
|
|
3179
|
-
* `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
|
|
3180
|
-
* latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
|
|
3181
|
-
* would (ADR-0017), so the box stays glued to the shape across seam calls.
|
|
3182
|
-
*/
|
|
3183
|
-
function commitRotate(angle) {
|
|
3184
|
-
if (ctx.settled) return;
|
|
3185
|
-
const preFreezeOptions = { ...workingOptions ?? {} };
|
|
3186
|
-
freezeOrientationForRotate();
|
|
3187
|
-
const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
|
|
3188
|
-
const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
|
|
3189
|
-
if (!pivot) return;
|
|
3190
|
-
boxAngle += angle;
|
|
3191
|
-
commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions, void 0, boxAngle - angle);
|
|
3192
|
-
}
|
|
3193
3443
|
function modesEqual(a, b) {
|
|
3194
3444
|
if (a.length !== b.length) return false;
|
|
3195
3445
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
@@ -3198,7 +3448,7 @@ function runEdit(opts) {
|
|
|
3198
3448
|
function applyModes(next) {
|
|
3199
3449
|
if (modesEqual(modes, next)) return;
|
|
3200
3450
|
modes = next;
|
|
3201
|
-
|
|
3451
|
+
labels.syncModes(modes);
|
|
3202
3452
|
renderHandlesLayer();
|
|
3203
3453
|
}
|
|
3204
3454
|
function drainPendingModes() {
|
|
@@ -3322,10 +3572,7 @@ function runEdit(opts) {
|
|
|
3322
3572
|
},
|
|
3323
3573
|
onChange(listener) {
|
|
3324
3574
|
if (ctx.settled) return () => {};
|
|
3325
|
-
|
|
3326
|
-
return () => {
|
|
3327
|
-
changeListeners.delete(listener);
|
|
3328
|
-
};
|
|
3575
|
+
return changeEmitter.register(listener);
|
|
3329
3576
|
},
|
|
3330
3577
|
onCommit(listener) {
|
|
3331
3578
|
if (ctx.settled) return () => {};
|
|
@@ -3333,24 +3580,6 @@ function runEdit(opts) {
|
|
|
3333
3580
|
},
|
|
3334
3581
|
onSettled(handler) {
|
|
3335
3582
|
return settleEmitter.register(handler);
|
|
3336
|
-
},
|
|
3337
|
-
[COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
|
|
3338
|
-
commitGesture(nextPoints);
|
|
3339
|
-
},
|
|
3340
|
-
[COMMIT_TRANSLATE_FOR_TEST]: (delta) => {
|
|
3341
|
-
commitTranslate(delta);
|
|
3342
|
-
},
|
|
3343
|
-
[COMMIT_ROTATE_FOR_TEST]: (angle) => {
|
|
3344
|
-
commitRotate(angle);
|
|
3345
|
-
},
|
|
3346
|
-
[BEGIN_DRAG_FOR_TEST]: () => {
|
|
3347
|
-
if (ctx.settled) return;
|
|
3348
|
-
dragInFlight = true;
|
|
3349
|
-
},
|
|
3350
|
-
[END_DRAG_FOR_TEST]: () => {
|
|
3351
|
-
if (ctx.settled) return;
|
|
3352
|
-
dragInFlight = false;
|
|
3353
|
-
drainPendingModes();
|
|
3354
3583
|
}
|
|
3355
3584
|
};
|
|
3356
3585
|
let activeDrag = null;
|
|
@@ -3387,83 +3616,6 @@ function runEdit(opts) {
|
|
|
3387
3616
|
return [pixel[0] + result.pixel[0] - anchorPixel[0], pixel[1] + result.pixel[1] - anchorPixel[1]];
|
|
3388
3617
|
}
|
|
3389
3618
|
/**
|
|
3390
|
-
* A live label *move* drag: direct label grab, the floating grip, or a body
|
|
3391
|
-
* grab inside the selected label's transform box (ADR-0017 box machinery
|
|
3392
|
-
* applied to labels — see `label-transform-box.ts`) — all three funnel
|
|
3393
|
-
* through this one state, distinguished only by `grabOffset`. `grabOffset`
|
|
3394
|
-
* is the anchor-pixel-minus-down-pixel delta captured at pointer-down, so
|
|
3395
|
-
* `labelAnchorFromPointer` can re-derive the intended anchor from any
|
|
3396
|
-
* subsequent pointer pixel without the label snapping to the pointer's
|
|
3397
|
-
* exact position (a direct grab derives `[0, 0]`; the floating grip derives
|
|
3398
|
-
* `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab derives whatever offset the
|
|
3399
|
-
* click landed at within the box).
|
|
3400
|
-
*/
|
|
3401
|
-
let activeAmplifierDrag = null;
|
|
3402
|
-
function pickLabel(pixel, tolerance) {
|
|
3403
|
-
for (const feature of lastPreviewFeatures) {
|
|
3404
|
-
const key = feature.properties?.labelPlacementKey;
|
|
3405
|
-
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
3406
|
-
const projected = driver.toPixel(feature.geometry.coordinates);
|
|
3407
|
-
if (!projected) continue;
|
|
3408
|
-
for (const handle of [false, true]) {
|
|
3409
|
-
const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
|
|
3410
|
-
if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
|
|
3411
|
-
key,
|
|
3412
|
-
anchorPixel: projected
|
|
3413
|
-
};
|
|
3414
|
-
}
|
|
3415
|
-
const box = fitLabelBoxFromFeature(feature, projected, {
|
|
3416
|
-
resolution: adapter.getResolution(),
|
|
3417
|
-
bearing: adapter.getBearing()
|
|
3418
|
-
});
|
|
3419
|
-
if (box && pointInBox(box, pixel)) return {
|
|
3420
|
-
key,
|
|
3421
|
-
anchorPixel: projected
|
|
3422
|
-
};
|
|
3423
|
-
}
|
|
3424
|
-
return null;
|
|
3425
|
-
}
|
|
3426
|
-
/**
|
|
3427
|
-
* The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
|
|
3428
|
-
* `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
|
|
3429
|
-
* see `activeAmplifierDrag`), so the label keeps whatever offset it was
|
|
3430
|
-
* grabbed at instead of snapping to the pointer.
|
|
3431
|
-
*/
|
|
3432
|
-
function labelAnchorFromPointer(pixel, grabOffset) {
|
|
3433
|
-
return driver.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
|
|
3434
|
-
}
|
|
3435
|
-
/**
|
|
3436
|
-
* The selected label's rendered feature + its current anchor pixel, or
|
|
3437
|
-
* `null` when nothing is selected, the feature is gone from this frame's
|
|
3438
|
-
* preview, or its anchor fails to project. The single lookup shared by the
|
|
3439
|
-
* box fit (`renderHandlesLayer`) and the box-pick paths (`onDriverDown`).
|
|
3440
|
-
*/
|
|
3441
|
-
function selectedLabelAnchor() {
|
|
3442
|
-
if (!selectedLabelKey) return null;
|
|
3443
|
-
const feature = lastPreviewFeatures.find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
|
|
3444
|
-
if (!feature || feature.geometry?.type !== "Point") return null;
|
|
3445
|
-
const pixel = driver.toPixel(feature.geometry.coordinates);
|
|
3446
|
-
if (!pixel) return null;
|
|
3447
|
-
return {
|
|
3448
|
-
feature,
|
|
3449
|
-
pixel
|
|
3450
|
-
};
|
|
3451
|
-
}
|
|
3452
|
-
/** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
|
|
3453
|
-
function pointerAngleAround(pixel, anchor) {
|
|
3454
|
-
return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
3455
|
-
}
|
|
3456
|
-
/**
|
|
3457
|
-
* A live label *rotate* drag (ADR-0017 box machinery applied to a label's
|
|
3458
|
-
* rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
|
|
3459
|
-
* `textSizeMeters` are captured at pointer-down so `lastBox` can be
|
|
3460
|
-
* re-fitted purely from drag math on every move tick — independent of
|
|
3461
|
-
* `lastPreviewFeatures`, which mirrors `activeBoxDrag.lastSnapshot` and
|
|
3462
|
-
* keeps the box painted even if a transient preview render fails
|
|
3463
|
-
* (`renderPreview`'s try/catch).
|
|
3464
|
-
*/
|
|
3465
|
-
let activeLabelRotateDrag = null;
|
|
3466
|
-
/**
|
|
3467
3619
|
* A live transform-box gesture (corner scale, box rotate, or body
|
|
3468
3620
|
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
3469
3621
|
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
@@ -3510,51 +3662,8 @@ function runEdit(opts) {
|
|
|
3510
3662
|
}
|
|
3511
3663
|
const onDriverDown = (pixel, info) => {
|
|
3512
3664
|
if (ctx.settled || dragInFlight) return;
|
|
3513
|
-
const tol = info?.hitTolerance ??
|
|
3514
|
-
if (modes.includes("labeldrag") &&
|
|
3515
|
-
const anchor = selectedLabelAnchor();
|
|
3516
|
-
if (anchor) {
|
|
3517
|
-
const gripPixel = rotateGripPixel(labelBox, 14);
|
|
3518
|
-
if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tol) {
|
|
3519
|
-
const props = anchor.feature.properties ?? {};
|
|
3520
|
-
activeLabelRotateDrag = {
|
|
3521
|
-
key: selectedLabelKey,
|
|
3522
|
-
anchorPixel: anchor.pixel,
|
|
3523
|
-
startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
|
|
3524
|
-
startRotation: typeof props.rotation === "number" ? props.rotation : 0,
|
|
3525
|
-
previous: snapshotAmplifierPlacements(),
|
|
3526
|
-
downPixel: pixel,
|
|
3527
|
-
text: typeof props.text === "string" ? props.text : void 0,
|
|
3528
|
-
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
3529
|
-
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
3530
|
-
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
3531
|
-
lastBox: labelBox
|
|
3532
|
-
};
|
|
3533
|
-
dragInFlight = true;
|
|
3534
|
-
driver.setDragPanEnabled(false);
|
|
3535
|
-
return;
|
|
3536
|
-
}
|
|
3537
|
-
if (pointInBox(labelBox, pixel)) {
|
|
3538
|
-
activeAmplifierDrag = {
|
|
3539
|
-
field: selectedLabelKey,
|
|
3540
|
-
grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
|
|
3541
|
-
previous: snapshotAmplifierPlacements(),
|
|
3542
|
-
downPixel: pixel
|
|
3543
|
-
};
|
|
3544
|
-
dragInFlight = true;
|
|
3545
|
-
driver.setDragPanEnabled(false);
|
|
3546
|
-
return;
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
}
|
|
3550
|
-
const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
|
|
3551
|
-
if (labelHit) {
|
|
3552
|
-
activeAmplifierDrag = {
|
|
3553
|
-
field: labelHit.key,
|
|
3554
|
-
grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
|
|
3555
|
-
previous: snapshotAmplifierPlacements(),
|
|
3556
|
-
downPixel: pixel
|
|
3557
|
-
};
|
|
3665
|
+
const tol = info?.hitTolerance ?? 12;
|
|
3666
|
+
if (modes.includes("labeldrag") && labels.onDown(pixel, tol)) {
|
|
3558
3667
|
dragInFlight = true;
|
|
3559
3668
|
driver.setDragPanEnabled(false);
|
|
3560
3669
|
return;
|
|
@@ -3568,13 +3677,13 @@ function runEdit(opts) {
|
|
|
3568
3677
|
if (modes.includes("delete")) {
|
|
3569
3678
|
if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
|
|
3570
3679
|
deleteVertex(hit.index);
|
|
3571
|
-
|
|
3680
|
+
shell.suppressNextClickAway();
|
|
3572
3681
|
}
|
|
3573
3682
|
return;
|
|
3574
3683
|
}
|
|
3575
3684
|
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
3576
3685
|
deleteVertex(hit.index);
|
|
3577
|
-
|
|
3686
|
+
shell.suppressNextClickAway();
|
|
3578
3687
|
return;
|
|
3579
3688
|
}
|
|
3580
3689
|
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
@@ -3655,39 +3764,17 @@ function runEdit(opts) {
|
|
|
3655
3764
|
}
|
|
3656
3765
|
const onDriverMove = (pixel, info) => {
|
|
3657
3766
|
if (ctx.settled) return;
|
|
3658
|
-
if (
|
|
3659
|
-
const
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
rotation,
|
|
3670
|
-
textAnchor: drag.textAnchor,
|
|
3671
|
-
textSizePixels: drag.textSizePixels,
|
|
3672
|
-
textSizeMeters: drag.textSizeMeters,
|
|
3673
|
-
anchor: drag.anchorPixel,
|
|
3674
|
-
resolution: adapter.getResolution(),
|
|
3675
|
-
bearing: adapter.getBearing()
|
|
3676
|
-
});
|
|
3677
|
-
if (liveBox) drag.lastBox = liveBox;
|
|
3678
|
-
renderPreview();
|
|
3679
|
-
renderHandlesLayer();
|
|
3680
|
-
} else if (activeAmplifierDrag) {
|
|
3681
|
-
const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
|
|
3682
|
-
if (!position) return;
|
|
3683
|
-
const field = activeAmplifierDrag.field;
|
|
3684
|
-
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[field], position);
|
|
3685
|
-
workingAmplifierPlacements = {
|
|
3686
|
-
...workingAmplifierPlacements ?? {},
|
|
3687
|
-
...merged !== void 0 ? { [field]: merged } : {}
|
|
3688
|
-
};
|
|
3689
|
-
renderPreview();
|
|
3690
|
-
renderHandlesLayer();
|
|
3767
|
+
if (labels.dragging) {
|
|
3768
|
+
const patch = labels.onMove(pixel);
|
|
3769
|
+
if (patch) {
|
|
3770
|
+
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[patch.field], patch.value);
|
|
3771
|
+
workingAmplifierPlacements = {
|
|
3772
|
+
...workingAmplifierPlacements ?? {},
|
|
3773
|
+
...merged !== void 0 ? { [patch.field]: merged } : {}
|
|
3774
|
+
};
|
|
3775
|
+
renderPreview();
|
|
3776
|
+
renderHandlesLayer();
|
|
3777
|
+
}
|
|
3691
3778
|
} else if (activeDrag) paintDragSnapshot(pixel, info);
|
|
3692
3779
|
else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info, info?.shiftKey);
|
|
3693
3780
|
};
|
|
@@ -3698,35 +3785,17 @@ function runEdit(opts) {
|
|
|
3698
3785
|
drainPendingModes();
|
|
3699
3786
|
}
|
|
3700
3787
|
const onDriverUp = (pixel, info) => {
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
workingAmplifierPlacements = drag.previous;
|
|
3705
|
-
endDragBookkeeping();
|
|
3706
|
-
if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$3) {
|
|
3707
|
-
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
3708
|
-
const rotation = drag.startRotation - delta;
|
|
3709
|
-
setAmplifierPlacements({ [drag.key]: { rotation } }, "gesture", "Rotate label");
|
|
3710
|
-
} else {
|
|
3711
|
-
renderPreview();
|
|
3712
|
-
renderHandlesLayer();
|
|
3713
|
-
}
|
|
3714
|
-
suppressClickAway = true;
|
|
3715
|
-
return;
|
|
3716
|
-
}
|
|
3717
|
-
if (activeAmplifierDrag) {
|
|
3718
|
-
const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
|
|
3719
|
-
activeAmplifierDrag = null;
|
|
3720
|
-
const position = labelAnchorFromPointer(pixel, grabOffset);
|
|
3721
|
-
workingAmplifierPlacements = previous;
|
|
3788
|
+
const labelOutcome = labels.onUp(pixel);
|
|
3789
|
+
if (labelOutcome) {
|
|
3790
|
+
workingAmplifierPlacements = labelOutcome.previous;
|
|
3722
3791
|
endDragBookkeeping();
|
|
3723
|
-
if (
|
|
3792
|
+
if (labelOutcome.kind === "rotate") setAmplifierPlacements({ [labelOutcome.key]: { rotation: labelOutcome.rotation } }, "gesture", "Rotate label");
|
|
3793
|
+
else if (labelOutcome.kind === "move") setAmplifierPlacements({ [labelOutcome.field]: labelOutcome.position }, "gesture", "Move label");
|
|
3724
3794
|
else {
|
|
3725
|
-
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
3726
3795
|
renderPreview();
|
|
3727
3796
|
renderHandlesLayer();
|
|
3728
3797
|
}
|
|
3729
|
-
|
|
3798
|
+
shell.suppressNextClickAway();
|
|
3730
3799
|
return;
|
|
3731
3800
|
}
|
|
3732
3801
|
if (activeDrag) {
|
|
@@ -3754,15 +3823,14 @@ function runEdit(opts) {
|
|
|
3754
3823
|
const { fsm, preDragPoints, preDragAmplifierPlacements, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
|
|
3755
3824
|
activeBoxDrag = null;
|
|
3756
3825
|
driver.setDragPanEnabled(true);
|
|
3757
|
-
|
|
3758
|
-
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$3) {
|
|
3826
|
+
if (fsm.kind === "body" && withinClickSlop(pixel, downPixel)) {
|
|
3759
3827
|
replaceWorkingPoints(preDragPoints);
|
|
3760
3828
|
workingAmplifierPlacements = preDragAmplifierPlacements;
|
|
3761
3829
|
renderPreview();
|
|
3762
3830
|
renderHandlesLayer();
|
|
3763
3831
|
recomputeDirty();
|
|
3764
3832
|
endDragBookkeeping();
|
|
3765
|
-
|
|
3833
|
+
shell.noteBodyClick();
|
|
3766
3834
|
return;
|
|
3767
3835
|
}
|
|
3768
3836
|
const finalPixel = translatedBoxPixel(pixel, info, {
|
|
@@ -3778,44 +3846,7 @@ function runEdit(opts) {
|
|
|
3778
3846
|
return;
|
|
3779
3847
|
}
|
|
3780
3848
|
};
|
|
3781
|
-
|
|
3782
|
-
let pickHandles;
|
|
3783
|
-
let pickGraphics;
|
|
3784
|
-
const handlePickPreview = (e) => {
|
|
3785
|
-
pickPreview = e;
|
|
3786
|
-
};
|
|
3787
|
-
const handlePickHandles = (e) => {
|
|
3788
|
-
pickHandles = e;
|
|
3789
|
-
};
|
|
3790
|
-
const handlePickGraphics = (e) => {
|
|
3791
|
-
pickGraphics = e;
|
|
3792
|
-
};
|
|
3793
|
-
const handleClick = () => {
|
|
3794
|
-
const localPreview = pickPreview;
|
|
3795
|
-
const localHandles = pickHandles;
|
|
3796
|
-
const localGraphics = pickGraphics;
|
|
3797
|
-
const wasBodyClick = bodyClickPending;
|
|
3798
|
-
pickPreview = void 0;
|
|
3799
|
-
pickHandles = void 0;
|
|
3800
|
-
pickGraphics = void 0;
|
|
3801
|
-
bodyClickPending = false;
|
|
3802
|
-
if (ctx.settled) return;
|
|
3803
|
-
if (!closeOnClickAway) return;
|
|
3804
|
-
if (suppressClickAway) {
|
|
3805
|
-
suppressClickAway = false;
|
|
3806
|
-
return;
|
|
3807
|
-
}
|
|
3808
|
-
if (localHandles) return;
|
|
3809
|
-
if (localGraphics && localGraphics.id !== measure.id) {
|
|
3810
|
-
ctx.commit(rebuildRendered);
|
|
3811
|
-
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
3812
|
-
return;
|
|
3813
|
-
}
|
|
3814
|
-
if (localPreview) return;
|
|
3815
|
-
if (wasBodyClick) return;
|
|
3816
|
-
ctx.commit(rebuildRendered);
|
|
3817
|
-
};
|
|
3818
|
-
const core = runInteraction({
|
|
3849
|
+
const core = runEditInteraction({
|
|
3819
3850
|
adapter,
|
|
3820
3851
|
signal,
|
|
3821
3852
|
layers: [
|
|
@@ -3823,44 +3854,37 @@ function runEdit(opts) {
|
|
|
3823
3854
|
guideLayer,
|
|
3824
3855
|
handlesLayer
|
|
3825
3856
|
],
|
|
3857
|
+
graphicsLayer,
|
|
3858
|
+
previewLayer,
|
|
3859
|
+
handlesLayer,
|
|
3860
|
+
closeOnClickAway,
|
|
3826
3861
|
onViewChange: rescaleOnViewChange,
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3862
|
+
onDown: onDriverDown,
|
|
3863
|
+
onMove: onDriverMove,
|
|
3864
|
+
onUp: onDriverUp,
|
|
3865
|
+
ownsGraphic: (id) => id === measure.id,
|
|
3866
|
+
close: () => {
|
|
3867
|
+
ctx.commit(rebuildRendered);
|
|
3868
|
+
},
|
|
3869
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
3870
|
+
historySettle: () => history.settle(),
|
|
3871
|
+
facadeOnSettled: onSettled,
|
|
3872
|
+
emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
|
|
3873
|
+
extraTeardown: () => {
|
|
3874
|
+
if (activeDrag !== null || activeBoxDrag !== null || labels.dragging) {
|
|
3875
|
+
driver.setDragPanEnabled(true);
|
|
3876
|
+
activeDrag = null;
|
|
3877
|
+
activeBoxDrag = null;
|
|
3878
|
+
labels.endDrag();
|
|
3879
|
+
dragInFlight = false;
|
|
3833
3880
|
}
|
|
3834
3881
|
},
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3882
|
+
onAbort: wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0,
|
|
3883
|
+
attach(interaction, createdDriver, createdShell) {
|
|
3884
|
+
ctx = interaction;
|
|
3885
|
+
driver = createdDriver;
|
|
3886
|
+
shell = createdShell;
|
|
3838
3887
|
liftOriginalFromGraphics();
|
|
3839
|
-
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
3840
|
-
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
3841
|
-
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
3842
|
-
const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
|
|
3843
|
-
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
3844
|
-
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
3845
|
-
adapter.on("click", handleClick);
|
|
3846
|
-
c.onTeardown(() => {
|
|
3847
|
-
adapter.off("click", handleClick);
|
|
3848
|
-
unsubPickPreview();
|
|
3849
|
-
unsubPickHandles();
|
|
3850
|
-
unsubPickGraphics();
|
|
3851
|
-
unsubDown();
|
|
3852
|
-
unsubMove();
|
|
3853
|
-
unsubUp();
|
|
3854
|
-
if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null || activeLabelRotateDrag !== null) {
|
|
3855
|
-
driver.setDragPanEnabled(true);
|
|
3856
|
-
activeDrag = null;
|
|
3857
|
-
activeBoxDrag = null;
|
|
3858
|
-
activeAmplifierDrag = null;
|
|
3859
|
-
activeLabelRotateDrag = null;
|
|
3860
|
-
dragInFlight = false;
|
|
3861
|
-
}
|
|
3862
|
-
driver.dispose();
|
|
3863
|
-
}, wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
|
|
3864
3888
|
renderPreview();
|
|
3865
3889
|
renderHandlesLayer();
|
|
3866
3890
|
recomputeDirty();
|
|
@@ -3922,125 +3946,279 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
|
3922
3946
|
return true;
|
|
3923
3947
|
}
|
|
3924
3948
|
//#endregion
|
|
3925
|
-
//#region src/tactical-draw/
|
|
3926
|
-
/**
|
|
3927
|
-
* Coordinate-space tolerance for `TransformSession.dirty` — mirrors
|
|
3928
|
-
* `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
|
|
3929
|
-
* than a shared import since the two controllers' dirty definitions may
|
|
3930
|
-
* diverge later: a group's dirty is "any member moved").
|
|
3931
|
-
*/
|
|
3932
|
-
const DIRTY_EPSILON = 1e-9;
|
|
3933
|
-
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
3934
|
-
/**
|
|
3935
|
-
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
3936
|
-
* lands within this many pixels of the down pixel is treated as a plain
|
|
3937
|
-
* click, not a gesture — same rationale as `edit-controller.ts`'s constant of
|
|
3938
|
-
* the same name.
|
|
3939
|
-
*/
|
|
3940
|
-
const CLICK_SLOP_PX$2 = 3;
|
|
3949
|
+
//#region src/tactical-draw/errors.ts
|
|
3941
3950
|
/**
|
|
3942
|
-
*
|
|
3943
|
-
*
|
|
3944
|
-
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
3945
|
-
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
3946
|
-
* grip, drag-anywhere body translate), applied rigidly to every member's
|
|
3947
|
-
* control points at once.
|
|
3948
|
-
*
|
|
3949
|
-
* - lift every member off the graphics layer + per-member working copies
|
|
3950
|
-
* - render every member's preview to the shared preview layer each paint
|
|
3951
|
-
* - group box = fit over the union of every member's rendered pixel
|
|
3952
|
-
* coordinates at the session `boxAngle`; each member also gets its own
|
|
3953
|
-
* thin outline (its own fit at the same angle)
|
|
3954
|
-
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
3955
|
-
* contracts as `runEdit`, scoped to the group
|
|
3956
|
-
* - membership is mutable via `session.setGraphics` (#187): members join/leave
|
|
3957
|
-
* a live session without a session boundary
|
|
3951
|
+
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
3952
|
+
* See PRD #40 / issue #45.
|
|
3958
3953
|
*/
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
function createMemberState(measure) {
|
|
3965
|
-
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
3966
|
-
return {
|
|
3967
|
-
measure,
|
|
3968
|
-
editStartPoints,
|
|
3969
|
-
workingPoints: editStartPoints.map((p) => [...p]),
|
|
3970
|
-
workingAmplifierPlacements: measure.amplifierPlacements ? structuredClone(measure.amplifierPlacements) : void 0,
|
|
3971
|
-
optionsOverride: null,
|
|
3972
|
-
box: null,
|
|
3973
|
-
lastPreviewFeatures: []
|
|
3974
|
-
};
|
|
3975
|
-
}
|
|
3976
|
-
/** The single mutable source of truth for the current member set. */
|
|
3977
|
-
let members = measures.map(createMemberState);
|
|
3978
|
-
/**
|
|
3979
|
-
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
3980
|
-
* mid-session `setGraphics` add). Gates the abort-path restore so a session
|
|
3981
|
-
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
3982
|
-
*/
|
|
3983
|
-
let anyLiftedFromGraphics = false;
|
|
3984
|
-
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
3985
|
-
function mergeOptions(base, override) {
|
|
3986
|
-
if (!override) return base;
|
|
3987
|
-
return {
|
|
3988
|
-
...base ?? {},
|
|
3989
|
-
...override
|
|
3990
|
-
};
|
|
3991
|
-
}
|
|
3992
|
-
/** Effective options for member `i`: its own options merged with its *live* override. */
|
|
3993
|
-
function effectiveOptions(i) {
|
|
3994
|
-
const member = members[i];
|
|
3995
|
-
return mergeOptions(member.measure.options, member.optionsOverride);
|
|
3954
|
+
var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
|
|
3955
|
+
constructor(message = "TacticalDraw instance has been destroyed") {
|
|
3956
|
+
super(message);
|
|
3957
|
+
this.name = "TacticalDrawDestroyedError";
|
|
3958
|
+
Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
|
|
3996
3959
|
}
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
controlPoints: member.workingPoints.map((p) => [...p]),
|
|
4003
|
-
...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
|
|
4004
|
-
};
|
|
4005
|
-
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
4006
|
-
return {
|
|
4007
|
-
graphic: cloneControlMeasure(committed),
|
|
4008
|
-
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
4009
|
-
};
|
|
3960
|
+
};
|
|
3961
|
+
var PointSymbolCapabilityRequiredError = class extends Error {
|
|
3962
|
+
constructor() {
|
|
3963
|
+
super("TacticalDraw: point-symbol capability is not configured");
|
|
3964
|
+
this.name = "PointSymbolCapabilityRequiredError";
|
|
4010
3965
|
}
|
|
4011
|
-
|
|
4012
|
-
|
|
3966
|
+
};
|
|
3967
|
+
//#endregion
|
|
3968
|
+
//#region src/tactical-draw/point-symbol-rendering.ts
|
|
3969
|
+
function effectivePointSymbolPixelSize(symbol, resolution) {
|
|
3970
|
+
if (symbol.size.unit === "pixels") return symbol.size.value;
|
|
3971
|
+
if (resolution === void 0 || resolution <= 0) return symbol.size.value;
|
|
3972
|
+
const px = symbol.size.value / resolution;
|
|
3973
|
+
const { minPixels, maxPixels } = symbol.size;
|
|
3974
|
+
return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
|
|
3975
|
+
}
|
|
3976
|
+
function pointSymbolPixelScale(symbol, render, resolution) {
|
|
3977
|
+
if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
|
|
3978
|
+
return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
|
|
3979
|
+
}
|
|
3980
|
+
/** Complete rotated logical footprint for selection and transform chrome. */
|
|
3981
|
+
function pointSymbolFootprint(symbol, render, adapter) {
|
|
3982
|
+
const anchor = adapter.getPixelFromCoordinate(symbol.position);
|
|
3983
|
+
if (!anchor) return null;
|
|
3984
|
+
const scale = pointSymbolPixelScale(symbol, render, adapter.getResolution());
|
|
3985
|
+
const angle = symbol.rotation - adapter.getBearing();
|
|
3986
|
+
const centerOffsetX = (render.width / 2 - render.anchor.x) * scale;
|
|
3987
|
+
const centerOffsetY = (render.height / 2 - render.anchor.y) * scale;
|
|
3988
|
+
const cos = Math.cos(angle);
|
|
3989
|
+
const sin = Math.sin(angle);
|
|
3990
|
+
const box = {
|
|
3991
|
+
center: [anchor[0] + centerOffsetX * cos - centerOffsetY * sin, anchor[1] + centerOffsetX * sin + centerOffsetY * cos],
|
|
3992
|
+
halfWidth: render.width * scale / 2,
|
|
3993
|
+
halfHeight: render.height * scale / 2,
|
|
3994
|
+
angle
|
|
3995
|
+
};
|
|
3996
|
+
return {
|
|
3997
|
+
box,
|
|
3998
|
+
corners: boxCorners(box)
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
function renderPointSymbolForAdapter(adapter, capability, symbol) {
|
|
4002
|
+
const render = capability.render(symbol);
|
|
4003
|
+
return {
|
|
4004
|
+
render,
|
|
4005
|
+
resource: {
|
|
4006
|
+
id: render.resourceId,
|
|
4007
|
+
svg: render.svg,
|
|
4008
|
+
width: render.width,
|
|
4009
|
+
height: render.height,
|
|
4010
|
+
anchor: [render.anchor.x, render.anchor.y]
|
|
4011
|
+
},
|
|
4012
|
+
feature: pointSymbolFeature(symbol, render, adapter.getResolution())
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
function pointSymbolFeature(symbol, render, resolution) {
|
|
4016
|
+
const groundSizeProperties = symbol.size.unit === "meters" ? {
|
|
4017
|
+
iconSizeMeters: symbol.size.value,
|
|
4018
|
+
iconOctagonSize: render.octagonSize,
|
|
4019
|
+
...boundProperty("iconMinPixels", symbol.size.minPixels),
|
|
4020
|
+
...boundProperty("iconMaxPixels", symbol.size.maxPixels)
|
|
4021
|
+
} : void 0;
|
|
4022
|
+
return {
|
|
4023
|
+
type: "Feature",
|
|
4024
|
+
id: `${symbol.id}:point-symbol`,
|
|
4025
|
+
geometry: {
|
|
4026
|
+
type: "Point",
|
|
4027
|
+
coordinates: [...symbol.position]
|
|
4028
|
+
},
|
|
4029
|
+
properties: {
|
|
4030
|
+
...symbol.properties ?? {},
|
|
4031
|
+
graphicId: symbol.id,
|
|
4032
|
+
graphicKind: "point-symbol",
|
|
4033
|
+
iconId: render.resourceId,
|
|
4034
|
+
iconScale: pointSymbolPixelScale(symbol, render, resolution),
|
|
4035
|
+
iconRotation: symbol.rotation,
|
|
4036
|
+
iconOpacity: symbol.style?.opacity ?? 1,
|
|
4037
|
+
...groundSizeProperties
|
|
4038
|
+
}
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
function boundProperty(key, value) {
|
|
4042
|
+
return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
|
|
4043
|
+
}
|
|
4044
|
+
//#endregion
|
|
4045
|
+
//#region src/tactical-draw/transform-controller.ts
|
|
4046
|
+
/** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
|
|
4047
|
+
const MIN_POINT_SYMBOL_SIZE = .1;
|
|
4048
|
+
/**
|
|
4049
|
+
* The uniform scale factor and rotation delta a box implies relative to a
|
|
4050
|
+
* pre-drag box, given the gesture kind. Body translate reports neither (1, 0);
|
|
4051
|
+
* measures ignore this and take their geometry straight from the transformed
|
|
4052
|
+
* anchor points — only point symbols consume it.
|
|
4053
|
+
*/
|
|
4054
|
+
function boxScaleRotation(kind, box, preDragBox) {
|
|
4055
|
+
return {
|
|
4056
|
+
scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
|
|
4057
|
+
rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
/** Apply a box's scale/rotation deltas to each member's pre-drag point-symbol state. */
|
|
4061
|
+
function scalePointSymbolStates(pre, scale, rotation) {
|
|
4062
|
+
return pre.map((state) => state ? {
|
|
4063
|
+
size: Math.max(MIN_POINT_SYMBOL_SIZE, state.size * scale),
|
|
4064
|
+
rotation: normalizeRotation(state.rotation + rotation)
|
|
4065
|
+
} : null);
|
|
4066
|
+
}
|
|
4067
|
+
/**
|
|
4068
|
+
* Drive an interactive group transform against a `MapAdapter` — the leaner,
|
|
4069
|
+
* multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
|
|
4070
|
+
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
4071
|
+
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
4072
|
+
* grip, drag-anywhere body translate), applied rigidly to every member at once.
|
|
4073
|
+
*
|
|
4074
|
+
* Typed on `Graphic` (ADR-0032): control measures and point symbols coexist in
|
|
4075
|
+
* one session. A measure's box gesture rewrites its control points (and bakes
|
|
4076
|
+
* pixel size per the group `sizeAnchor`); a point symbol's rewrites its
|
|
4077
|
+
* position, scales its size value, and rotates its rotation — its persisted
|
|
4078
|
+
* size unit is never converted.
|
|
4079
|
+
*
|
|
4080
|
+
* - lift every member off the graphics layer + per-member working copies
|
|
4081
|
+
* - render every member's preview to the shared preview layer each paint
|
|
4082
|
+
* - group box = fit over the union of every member's rendered pixel footprint
|
|
4083
|
+
* at the session `boxAngle`; each member also gets its own thin outline
|
|
4084
|
+
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
4085
|
+
* contracts as `runEdit`, scoped to the group
|
|
4086
|
+
* - membership is mutable via `session.setGraphics` (#187): members join/leave
|
|
4087
|
+
* a live session without a session boundary
|
|
4088
|
+
*/
|
|
4089
|
+
function runGroupTransform(opts) {
|
|
4090
|
+
const { adapter, capability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
4091
|
+
assertCapabilityFor(graphics);
|
|
4092
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
4093
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
4094
|
+
/** Throw synchronously if any graphic in `list` is a point symbol and no capability was supplied. */
|
|
4095
|
+
function assertCapabilityFor(list) {
|
|
4096
|
+
if (!capability && list.some(isPointSymbol)) throw new PointSymbolCapabilityRequiredError();
|
|
4097
|
+
}
|
|
4098
|
+
function isPointSymbolMember(member) {
|
|
4099
|
+
return isPointSymbol(member.graphic);
|
|
4100
|
+
}
|
|
4101
|
+
/** Build a fresh working record for `graphic`, snapshotting its edit-start state. */
|
|
4102
|
+
function createMemberState(graphic) {
|
|
4103
|
+
if (isPointSymbol(graphic)) {
|
|
4104
|
+
const editStartPoints = [[...graphic.position]];
|
|
4105
|
+
return {
|
|
4106
|
+
graphic,
|
|
4107
|
+
editStartPoints,
|
|
4108
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
4109
|
+
editStartSize: graphic.size.value,
|
|
4110
|
+
editStartRotation: graphic.rotation,
|
|
4111
|
+
workingSize: graphic.size.value,
|
|
4112
|
+
workingRotation: graphic.rotation,
|
|
4113
|
+
workingAmplifierPlacements: void 0,
|
|
4114
|
+
optionsOverride: null,
|
|
4115
|
+
box: null,
|
|
4116
|
+
lastBoxPixels: []
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
const editStartPoints = graphic.controlPoints.map((p) => [...p]);
|
|
4120
|
+
return {
|
|
4121
|
+
graphic,
|
|
4122
|
+
editStartPoints,
|
|
4123
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
4124
|
+
editStartSize: 0,
|
|
4125
|
+
editStartRotation: 0,
|
|
4126
|
+
workingSize: 0,
|
|
4127
|
+
workingRotation: 0,
|
|
4128
|
+
workingAmplifierPlacements: graphic.amplifierPlacements ? structuredClone(graphic.amplifierPlacements) : void 0,
|
|
4129
|
+
optionsOverride: null,
|
|
4130
|
+
box: null,
|
|
4131
|
+
lastBoxPixels: []
|
|
4132
|
+
};
|
|
4133
|
+
}
|
|
4134
|
+
/** The single mutable source of truth for the current member set. */
|
|
4135
|
+
let members = graphics.map(createMemberState);
|
|
4136
|
+
/**
|
|
4137
|
+
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
4138
|
+
* mid-session `setGraphics` add). Gates the abort-path restore so a session
|
|
4139
|
+
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
4140
|
+
*/
|
|
4141
|
+
let anyLiftedFromGraphics = false;
|
|
4142
|
+
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
4143
|
+
function mergeOptions(base, override) {
|
|
4144
|
+
if (!override) return base;
|
|
4145
|
+
return {
|
|
4146
|
+
...base ?? {},
|
|
4147
|
+
...override
|
|
4148
|
+
};
|
|
4149
|
+
}
|
|
4150
|
+
/** Effective options for measure member `i`: its own options merged with its *live* override. */
|
|
4151
|
+
function effectiveOptions(i) {
|
|
4013
4152
|
const member = members[i];
|
|
4153
|
+
return mergeOptions(member.graphic.options, member.optionsOverride);
|
|
4154
|
+
}
|
|
4155
|
+
/** Build the working point symbol for a member from its live working state. */
|
|
4156
|
+
function buildWorkingPointSymbol(member) {
|
|
4157
|
+
const symbol = clonePointSymbol(member.graphic);
|
|
4158
|
+
symbol.position = [...member.workingPoints[0]];
|
|
4159
|
+
symbol.size = resizePointSymbolSize(symbol.size, member.workingSize);
|
|
4160
|
+
symbol.rotation = member.workingRotation;
|
|
4161
|
+
return symbol;
|
|
4162
|
+
}
|
|
4163
|
+
/** Build the working measure for member `i` with a given options override. */
|
|
4164
|
+
function buildWorkingMeasureWith(member, override) {
|
|
4165
|
+
const measure = member.graphic;
|
|
4166
|
+
const options = mergeOptions(measure.options, override);
|
|
4014
4167
|
return {
|
|
4015
|
-
...
|
|
4016
|
-
options: effectiveOptions(i),
|
|
4168
|
+
...measure,
|
|
4017
4169
|
controlPoints: member.workingPoints.map((p) => [...p]),
|
|
4170
|
+
...options !== void 0 ? { options } : {},
|
|
4018
4171
|
...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
|
|
4019
4172
|
};
|
|
4020
4173
|
}
|
|
4174
|
+
/** Build a member's committed snapshot with a given override; point symbols never bake. */
|
|
4175
|
+
function buildMemberSnapshot(member, override) {
|
|
4176
|
+
if (isPointSymbolMember(member)) {
|
|
4177
|
+
const symbol = buildWorkingPointSymbol(member);
|
|
4178
|
+
return {
|
|
4179
|
+
graphic: clonePointSymbol(symbol),
|
|
4180
|
+
render: capability.render(symbol)
|
|
4181
|
+
};
|
|
4182
|
+
}
|
|
4183
|
+
const working = buildWorkingMeasureWith(member, override);
|
|
4184
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
4185
|
+
return {
|
|
4186
|
+
graphic: cloneControlMeasure(committed),
|
|
4187
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
4188
|
+
};
|
|
4189
|
+
}
|
|
4190
|
+
/** The working graphic for member `i` (measure or point symbol), for preview and `workingGraphics`. */
|
|
4191
|
+
function workingGraphicFor(i) {
|
|
4192
|
+
const member = members[i];
|
|
4193
|
+
if (isPointSymbolMember(member)) return buildWorkingPointSymbol(member);
|
|
4194
|
+
return buildWorkingMeasureWith(member, member.optionsOverride);
|
|
4195
|
+
}
|
|
4021
4196
|
let dirty = false;
|
|
4022
4197
|
/**
|
|
4023
4198
|
* Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
|
|
4024
|
-
* even when every control point still matches edit-start
|
|
4025
|
-
* `edit-controller.ts`'s flag of the same name.
|
|
4199
|
+
* even when every control point still matches edit-start.
|
|
4026
4200
|
*/
|
|
4027
4201
|
let nonGeometryDirty = false;
|
|
4028
4202
|
let dragInFlight = false;
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4203
|
+
const changeEmitter = createChangeEmitter({
|
|
4204
|
+
listenerErrorsMessage: "TransformSession.onChange listener errors",
|
|
4205
|
+
isSettled: () => ctx.settled,
|
|
4206
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
4207
|
+
close: () => {
|
|
4208
|
+
commitClose();
|
|
4209
|
+
}
|
|
4210
|
+
});
|
|
4034
4211
|
const commitEmitter = createCommitEmitter("TransformSession.onCommit listener errors");
|
|
4035
4212
|
const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
|
|
4036
4213
|
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
4037
4214
|
let boxAngle = 0;
|
|
4038
4215
|
function captureHistory() {
|
|
4039
4216
|
return {
|
|
4040
|
-
ids: members.map((member) => member.
|
|
4217
|
+
ids: members.map((member) => member.graphic.id),
|
|
4041
4218
|
points: clonePointsByMember(),
|
|
4042
4219
|
amplifierPlacements: cloneAmplifierPlacementsByMember(),
|
|
4043
4220
|
optionsOverrides: cloneOptionsOverrideByMember(),
|
|
4221
|
+
pointSymbolStates: clonePointSymbolStateByMember(),
|
|
4044
4222
|
sizeAnchor,
|
|
4045
4223
|
nonGeometryDirty,
|
|
4046
4224
|
boxAngle
|
|
@@ -4050,6 +4228,7 @@ function runGroupTransform(opts) {
|
|
|
4050
4228
|
restorePointsByMember(value.points);
|
|
4051
4229
|
applyAmplifierPlacementsByMember(value.amplifierPlacements.map((placements) => placements ? structuredClone(placements) : void 0));
|
|
4052
4230
|
applyOptionsOverrides(value.optionsOverrides);
|
|
4231
|
+
applyPointSymbolStateByMember(value.pointSymbolStates);
|
|
4053
4232
|
sizeAnchor = value.sizeAnchor;
|
|
4054
4233
|
nonGeometryDirty = value.nonGeometryDirty;
|
|
4055
4234
|
boxAngle = value.boxAngle;
|
|
@@ -4058,6 +4237,7 @@ function runGroupTransform(opts) {
|
|
|
4058
4237
|
let groupBox = null;
|
|
4059
4238
|
let ctx;
|
|
4060
4239
|
let driver;
|
|
4240
|
+
let shell;
|
|
4061
4241
|
const dragProjection = {
|
|
4062
4242
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
4063
4243
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
@@ -4068,12 +4248,15 @@ function runGroupTransform(opts) {
|
|
|
4068
4248
|
* Used for both initial and mid-session-added members.
|
|
4069
4249
|
*/
|
|
4070
4250
|
function liftMemberFromGraphics(member) {
|
|
4071
|
-
if (!isOnGraphicsLayer(member.
|
|
4072
|
-
|
|
4073
|
-
|
|
4251
|
+
if (!isOnGraphicsLayer(member.graphic.id)) return;
|
|
4252
|
+
if (isPointSymbol(member.graphic)) adapter.removeFeatures(graphicsLayer, [`${member.graphic.id}:point-symbol`]);
|
|
4253
|
+
else {
|
|
4254
|
+
const ids = renderMeasureForAdapter(adapter, member.graphic, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
4255
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
4256
|
+
}
|
|
4074
4257
|
anyLiftedFromGraphics = true;
|
|
4075
4258
|
}
|
|
4076
|
-
function
|
|
4259
|
+
function pixelsFromFeatures(features) {
|
|
4077
4260
|
const positions = [];
|
|
4078
4261
|
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
4079
4262
|
const pixels = [];
|
|
@@ -4088,7 +4271,7 @@ function runGroupTransform(opts) {
|
|
|
4088
4271
|
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
4089
4272
|
}
|
|
4090
4273
|
/**
|
|
4091
|
-
* Re-fit `groupBox` and every member's `box` from their `
|
|
4274
|
+
* Re-fit `groupBox` and every member's `box` from their `lastBoxPixels`.
|
|
4092
4275
|
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
4093
4276
|
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
4094
4277
|
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
@@ -4100,9 +4283,8 @@ function runGroupTransform(opts) {
|
|
|
4100
4283
|
const padding = interactionStyle?.boxPadding ?? 0;
|
|
4101
4284
|
const allPixels = [];
|
|
4102
4285
|
for (const member of members) {
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
member.box = fitTransformBox(pixels, angle, padding);
|
|
4286
|
+
allPixels.push(...member.lastBoxPixels);
|
|
4287
|
+
member.box = fitTransformBox(member.lastBoxPixels, angle, padding);
|
|
4106
4288
|
}
|
|
4107
4289
|
if (activeBoxDrag) return;
|
|
4108
4290
|
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
@@ -4110,25 +4292,38 @@ function runGroupTransform(opts) {
|
|
|
4110
4292
|
function renderAllPreviews() {
|
|
4111
4293
|
if (ctx.settled) return;
|
|
4112
4294
|
const allFeatures = [];
|
|
4295
|
+
const resources = /* @__PURE__ */ new Map();
|
|
4113
4296
|
members.forEach((member, i) => {
|
|
4114
|
-
const working = workingMeasureFor(i);
|
|
4115
4297
|
let features;
|
|
4298
|
+
let boxPixels = [];
|
|
4116
4299
|
try {
|
|
4117
|
-
|
|
4300
|
+
if (isPointSymbolMember(member)) {
|
|
4301
|
+
const symbol = buildWorkingPointSymbol(member);
|
|
4302
|
+
const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
|
|
4303
|
+
resources.set(plan.resource.id, plan.resource);
|
|
4304
|
+
features = [plan.feature];
|
|
4305
|
+
const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
|
|
4306
|
+
if (footprint) boxPixels = footprint.corners;
|
|
4307
|
+
} else {
|
|
4308
|
+
features = renderMeasureForAdapter(adapter, workingGraphicFor(i), renderOpts).features;
|
|
4309
|
+
boxPixels = pixelsFromFeatures(features);
|
|
4310
|
+
}
|
|
4118
4311
|
} catch {
|
|
4119
4312
|
features = [];
|
|
4313
|
+
boxPixels = [];
|
|
4120
4314
|
}
|
|
4121
|
-
member.
|
|
4315
|
+
member.lastBoxPixels = boxPixels;
|
|
4122
4316
|
allFeatures.push(...features);
|
|
4123
4317
|
});
|
|
4318
|
+
adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
|
|
4124
4319
|
ctx.writeLayer(previewLayer, allFeatures);
|
|
4125
4320
|
refitBoxes();
|
|
4126
4321
|
}
|
|
4127
4322
|
function rescaleOnViewChange() {
|
|
4128
4323
|
if (ctx.settled) return;
|
|
4129
|
-
if (members.some((m) => measureRendersFromViewState({
|
|
4130
|
-
kind: m.
|
|
4131
|
-
options: m.
|
|
4324
|
+
if (members.some((m) => isPointSymbolMember(m) || measureRendersFromViewState({
|
|
4325
|
+
kind: m.graphic.kind,
|
|
4326
|
+
options: m.graphic.options
|
|
4132
4327
|
}))) renderAllPreviews();
|
|
4133
4328
|
else refitBoxes();
|
|
4134
4329
|
renderHandlesLayer();
|
|
@@ -4141,7 +4336,7 @@ function runGroupTransform(opts) {
|
|
|
4141
4336
|
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle(interactionStyle?.boxOutline)));
|
|
4142
4337
|
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
4143
4338
|
}
|
|
4144
|
-
features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.
|
|
4339
|
+
features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.graphic.id, member.box]] : []), dragProjection.fromPixel, handleIdPrefix, interactionStyle?.boxOutline));
|
|
4145
4340
|
ctx.writeLayer(handlesLayer, features);
|
|
4146
4341
|
}
|
|
4147
4342
|
/**
|
|
@@ -4158,9 +4353,9 @@ function runGroupTransform(opts) {
|
|
|
4158
4353
|
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
4159
4354
|
}
|
|
4160
4355
|
function recomputeDirty() {
|
|
4161
|
-
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints,
|
|
4356
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, 1e-9) || isPointSymbolMember(m) && (m.workingSize !== m.editStartSize || m.workingRotation !== m.editStartRotation));
|
|
4162
4357
|
}
|
|
4163
|
-
/** Split `points` (the concatenation of every member's
|
|
4358
|
+
/** Split `points` (the concatenation of every member's anchor points) back per member. */
|
|
4164
4359
|
function applyConcatenatedPoints(points) {
|
|
4165
4360
|
const rounded = roundControlPoints(points);
|
|
4166
4361
|
let offset = 0;
|
|
@@ -4184,6 +4379,23 @@ function runGroupTransform(opts) {
|
|
|
4184
4379
|
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
4185
4380
|
});
|
|
4186
4381
|
}
|
|
4382
|
+
/** Snapshot each point-symbol member's working size/rotation (`null` for measures). */
|
|
4383
|
+
function clonePointSymbolStateByMember() {
|
|
4384
|
+
return members.map((m) => isPointSymbolMember(m) ? {
|
|
4385
|
+
size: m.workingSize,
|
|
4386
|
+
rotation: m.workingRotation
|
|
4387
|
+
} : null);
|
|
4388
|
+
}
|
|
4389
|
+
/** Write a point-symbol size/rotation snapshot back onto the live members (index-aligned). */
|
|
4390
|
+
function applyPointSymbolStateByMember(states) {
|
|
4391
|
+
members.forEach((member, i) => {
|
|
4392
|
+
const state = states[i];
|
|
4393
|
+
if (state && isPointSymbolMember(member)) {
|
|
4394
|
+
member.workingSize = state.size;
|
|
4395
|
+
member.workingRotation = state.rotation;
|
|
4396
|
+
}
|
|
4397
|
+
});
|
|
4398
|
+
}
|
|
4187
4399
|
function cloneAmplifierPlacementsByMember() {
|
|
4188
4400
|
return members.map((member) => member.workingAmplifierPlacements ? structuredClone(member.workingAmplifierPlacements) : void 0);
|
|
4189
4401
|
}
|
|
@@ -4211,13 +4423,15 @@ function runGroupTransform(opts) {
|
|
|
4211
4423
|
}
|
|
4212
4424
|
/**
|
|
4213
4425
|
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
4214
|
-
* anchor) for every member, the moment a group rotate gesture starts —
|
|
4426
|
+
* anchor) for every measure member, the moment a group rotate gesture starts —
|
|
4215
4427
|
* mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
|
|
4216
|
-
* per-member since the group has no single working-options set.
|
|
4428
|
+
* per-member since the group has no single working-options set. Point-symbol
|
|
4429
|
+
* members carry no such options and are skipped.
|
|
4217
4430
|
*/
|
|
4218
4431
|
function freezeOrientationForRotate() {
|
|
4219
4432
|
members.forEach((member, i) => {
|
|
4220
|
-
|
|
4433
|
+
if (isPointSymbolMember(member)) return;
|
|
4434
|
+
const patch = freezeOrientationOptions(member.graphic.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
4221
4435
|
if (patch) member.optionsOverride = {
|
|
4222
4436
|
...member.optionsOverride ?? {},
|
|
4223
4437
|
...patch
|
|
@@ -4225,54 +4439,35 @@ function runGroupTransform(opts) {
|
|
|
4225
4439
|
});
|
|
4226
4440
|
}
|
|
4227
4441
|
/**
|
|
4228
|
-
* Emit a completed gesture
|
|
4229
|
-
*
|
|
4230
|
-
*
|
|
4231
|
-
*
|
|
4442
|
+
* Emit a completed gesture through the shared change-emit protocol
|
|
4443
|
+
* (`createChangeEmitter`, issue #49) — the group mirror of
|
|
4444
|
+
* `edit-controller.ts`'s `emitChange`. Only this controller's variance stays
|
|
4445
|
+
* here: the event payload, the per-member reject rollback, the
|
|
4446
|
+
* membership-identity guard, and the recordable-source history record.
|
|
4232
4447
|
*/
|
|
4233
|
-
function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, source = "gesture", historyLabel = "Transform", previousBoxAngle = boxAngle) {
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
let closeRequested = false;
|
|
4237
|
-
let abortRequested = false;
|
|
4238
|
-
const event = {
|
|
4448
|
+
function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, source = "gesture", historyLabel = "Transform", previousBoxAngle = boxAngle, previousPointSymbolStateByMember = clonePointSymbolStateByMember()) {
|
|
4449
|
+
const membersAtEmission = members;
|
|
4450
|
+
changeEmitter.emit({
|
|
4239
4451
|
source,
|
|
4240
4452
|
graphics: currentRendered,
|
|
4241
4453
|
previous: previousRendered,
|
|
4242
|
-
session
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4454
|
+
session
|
|
4455
|
+
}, {
|
|
4456
|
+
stillApplicable: () => members === membersAtEmission,
|
|
4457
|
+
rollback() {
|
|
4458
|
+
restorePointsByMember(previousPointsByMember);
|
|
4459
|
+
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
4460
|
+
applyPointSymbolStateByMember(previousPointSymbolStateByMember);
|
|
4461
|
+
sizeAnchor = previousSizeAnchor;
|
|
4462
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
4463
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
4464
|
+
boxAngle = previousBoxAngle;
|
|
4465
|
+
renderAllPreviews();
|
|
4466
|
+
renderHandlesLayer();
|
|
4467
|
+
recomputeDirty();
|
|
4248
4468
|
},
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
}
|
|
4252
|
-
};
|
|
4253
|
-
const membersAtEmission = members;
|
|
4254
|
-
const errors = [];
|
|
4255
|
-
for (const listener of [...changeListeners]) try {
|
|
4256
|
-
listener(event);
|
|
4257
|
-
} catch (err) {
|
|
4258
|
-
errors.push(err);
|
|
4259
|
-
}
|
|
4260
|
-
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
4261
|
-
else if (closeRequested) commitClose();
|
|
4262
|
-
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
4263
|
-
restorePointsByMember(previousPointsByMember);
|
|
4264
|
-
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
4265
|
-
sizeAnchor = previousSizeAnchor;
|
|
4266
|
-
nonGeometryDirty = previousNonGeometryDirty;
|
|
4267
|
-
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
4268
|
-
boxAngle = previousBoxAngle;
|
|
4269
|
-
renderAllPreviews();
|
|
4270
|
-
renderHandlesLayer();
|
|
4271
|
-
recomputeDirty();
|
|
4272
|
-
}
|
|
4273
|
-
if (!ctx.settled && !rejectRequested && members === membersAtEmission && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
|
|
4274
|
-
if (errors.length === 1) throw errors[0];
|
|
4275
|
-
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
|
|
4469
|
+
onAccepted: source === "gesture" || source === "programmatic" ? () => history.record(captureHistory(), historyLabel) : void 0
|
|
4470
|
+
});
|
|
4276
4471
|
}
|
|
4277
4472
|
/**
|
|
4278
4473
|
* Commit via `ctx.commit` and emit the close batch to `onCommit` listeners,
|
|
@@ -4296,13 +4491,15 @@ function runGroupTransform(opts) {
|
|
|
4296
4491
|
}
|
|
4297
4492
|
return snapshots;
|
|
4298
4493
|
}
|
|
4299
|
-
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle) {
|
|
4494
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle, nextPointSymbolStateByMember) {
|
|
4300
4495
|
if (ctx.settled) return;
|
|
4301
4496
|
const previousPointsByMember = clonePointsByMember();
|
|
4302
4497
|
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4498
|
+
const previousPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4303
4499
|
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
4304
4500
|
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
4305
4501
|
applyConcatenatedPoints(nextConcatPoints);
|
|
4502
|
+
if (nextPointSymbolStateByMember) applyPointSymbolStateByMember(nextPointSymbolStateByMember);
|
|
4306
4503
|
if (nextAmplifierPlacementsByMember) applyAmplifierPlacementsByMember(nextAmplifierPlacementsByMember);
|
|
4307
4504
|
let currentRendered;
|
|
4308
4505
|
try {
|
|
@@ -4310,6 +4507,7 @@ function runGroupTransform(opts) {
|
|
|
4310
4507
|
} catch (err) {
|
|
4311
4508
|
restorePointsByMember(previousPointsByMember);
|
|
4312
4509
|
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
4510
|
+
applyPointSymbolStateByMember(previousPointSymbolStateByMember);
|
|
4313
4511
|
dragInFlight = false;
|
|
4314
4512
|
throw err;
|
|
4315
4513
|
}
|
|
@@ -4317,7 +4515,7 @@ function runGroupTransform(opts) {
|
|
|
4317
4515
|
renderHandlesLayer();
|
|
4318
4516
|
recomputeDirty();
|
|
4319
4517
|
dragInFlight = false;
|
|
4320
|
-
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle);
|
|
4518
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle, previousPointSymbolStateByMember);
|
|
4321
4519
|
}
|
|
4322
4520
|
function setSizeAnchor(next) {
|
|
4323
4521
|
if (ctx.settled || sizeAnchor === next) return;
|
|
@@ -4326,12 +4524,13 @@ function runGroupTransform(opts) {
|
|
|
4326
4524
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
4327
4525
|
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
4328
4526
|
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4527
|
+
const previousPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4329
4528
|
const previousRendered = rebuildRenderedAll();
|
|
4330
4529
|
sizeAnchor = next;
|
|
4331
4530
|
const currentRendered = rebuildRenderedAll();
|
|
4332
4531
|
nonGeometryDirty = true;
|
|
4333
4532
|
dirty = true;
|
|
4334
|
-
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor");
|
|
4533
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor", boxAngle, previousPointSymbolStateByMember);
|
|
4335
4534
|
}
|
|
4336
4535
|
/**
|
|
4337
4536
|
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
@@ -4343,663 +4542,252 @@ function runGroupTransform(opts) {
|
|
|
4343
4542
|
*/
|
|
4344
4543
|
function cancelActiveBoxDrag() {
|
|
4345
4544
|
if (!activeBoxDrag) return;
|
|
4346
|
-
const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
4545
|
+
const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember, preDragPointSymbolStateByMember } = activeBoxDrag;
|
|
4347
4546
|
activeBoxDrag = null;
|
|
4348
4547
|
driver.setDragPanEnabled(true);
|
|
4349
4548
|
applyConcatenatedPoints(preDragPoints);
|
|
4350
4549
|
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4351
4550
|
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
4551
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4352
4552
|
dragInFlight = false;
|
|
4353
4553
|
}
|
|
4354
4554
|
function setGraphics(next) {
|
|
4355
4555
|
if (ctx.settled) return void 0;
|
|
4356
4556
|
const seen = /* @__PURE__ */ new Set();
|
|
4357
|
-
for (const
|
|
4358
|
-
if (seen.has(
|
|
4359
|
-
seen.add(
|
|
4557
|
+
for (const g of next) {
|
|
4558
|
+
if (seen.has(g.id)) throw new TypeError(`TransformSession.setGraphics: duplicate graphic id "${g.id}"`);
|
|
4559
|
+
seen.add(g.id);
|
|
4360
4560
|
}
|
|
4561
|
+
const currentIds = new Set(members.map((m) => m.graphic.id));
|
|
4562
|
+
assertCapabilityFor(next.filter((g) => !currentIds.has(g.id)));
|
|
4361
4563
|
if (next.length === 0) return close();
|
|
4362
4564
|
cancelActiveBoxDrag();
|
|
4363
|
-
const nextIds = new Set(next.map((
|
|
4364
|
-
const currentById = new Map(members.map((m) => [m.
|
|
4565
|
+
const nextIds = new Set(next.map((g) => g.id));
|
|
4566
|
+
const currentById = new Map(members.map((m) => [m.graphic.id, m]));
|
|
4365
4567
|
const removedSnapshots = [];
|
|
4366
|
-
for (const member of members) if (!nextIds.has(member.
|
|
4568
|
+
for (const member of members) if (!nextIds.has(member.graphic.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
4367
4569
|
const nextMembers = [];
|
|
4368
|
-
for (const
|
|
4369
|
-
const existing = currentById.get(
|
|
4570
|
+
for (const graphic of next) {
|
|
4571
|
+
const existing = currentById.get(graphic.id);
|
|
4370
4572
|
if (existing) nextMembers.push(existing);
|
|
4371
4573
|
else {
|
|
4372
|
-
const member = createMemberState(
|
|
4574
|
+
const member = createMemberState(graphic);
|
|
4373
4575
|
liftMemberFromGraphics(member);
|
|
4374
|
-
nextMembers.push(member);
|
|
4375
|
-
}
|
|
4376
|
-
}
|
|
4377
|
-
members = nextMembers;
|
|
4378
|
-
renderAllPreviews();
|
|
4379
|
-
renderHandlesLayer();
|
|
4380
|
-
recomputeDirty();
|
|
4381
|
-
const notificationErrors = [];
|
|
4382
|
-
try {
|
|
4383
|
-
history.reset(captureHistory());
|
|
4384
|
-
} catch (error) {
|
|
4385
|
-
notificationErrors.push(error);
|
|
4386
|
-
}
|
|
4387
|
-
if (removedSnapshots.length > 0) try {
|
|
4388
|
-
commitEmitter.emit(removedSnapshots);
|
|
4389
|
-
} catch (error) {
|
|
4390
|
-
notificationErrors.push(error);
|
|
4391
|
-
}
|
|
4392
|
-
if (notificationErrors.length === 1) throw notificationErrors[0];
|
|
4393
|
-
if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
|
|
4394
|
-
return removedSnapshots;
|
|
4395
|
-
}
|
|
4396
|
-
function close() {
|
|
4397
|
-
return commitClose();
|
|
4398
|
-
}
|
|
4399
|
-
const history = createSessionHistory({
|
|
4400
|
-
initial: captureHistory(),
|
|
4401
|
-
clone: (value) => structuredClone(value),
|
|
4402
|
-
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
4403
|
-
isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
|
|
4404
|
-
apply(from, to, source) {
|
|
4405
|
-
if (JSON.stringify(from.ids) !== JSON.stringify(to.ids)) return false;
|
|
4406
|
-
const previousRendered = rebuildRenderedAll();
|
|
4407
|
-
restoreHistory(to);
|
|
4408
|
-
renderAllPreviews();
|
|
4409
|
-
renderHandlesLayer();
|
|
4410
|
-
recomputeDirty();
|
|
4411
|
-
emitChange(previousRendered, from.points, from.amplifierPlacements, from.sizeAnchor, from.nonGeometryDirty, from.optionsOverrides, rebuildRenderedAll(), source, "Transform", from.boxAngle);
|
|
4412
|
-
return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
|
|
4413
|
-
}
|
|
4414
|
-
});
|
|
4415
|
-
const session = {
|
|
4416
|
-
history,
|
|
4417
|
-
get graphics() {
|
|
4418
|
-
return members.map((m) => m.measure);
|
|
4419
|
-
},
|
|
4420
|
-
controlPointsFor(graphicId) {
|
|
4421
|
-
const member = members.find((m) => m.measure.id === graphicId);
|
|
4422
|
-
if (!member) return void 0;
|
|
4423
|
-
return member.workingPoints.map((p) => [...p]);
|
|
4424
|
-
},
|
|
4425
|
-
get dirty() {
|
|
4426
|
-
return dirty;
|
|
4427
|
-
},
|
|
4428
|
-
get sizeAnchor() {
|
|
4429
|
-
return sizeAnchor;
|
|
4430
|
-
},
|
|
4431
|
-
setSizeAnchor(next) {
|
|
4432
|
-
setSizeAnchor(next);
|
|
4433
|
-
},
|
|
4434
|
-
setGraphics,
|
|
4435
|
-
close,
|
|
4436
|
-
abort() {
|
|
4437
|
-
if (ctx.settled) return;
|
|
4438
|
-
ctx.fail(new TacticalDrawAbortError("session"));
|
|
4439
|
-
},
|
|
4440
|
-
onChange(listener) {
|
|
4441
|
-
if (ctx.settled) return () => {};
|
|
4442
|
-
changeListeners.add(listener);
|
|
4443
|
-
return () => {
|
|
4444
|
-
changeListeners.delete(listener);
|
|
4445
|
-
};
|
|
4446
|
-
},
|
|
4447
|
-
onCommit(listener) {
|
|
4448
|
-
if (ctx.settled) return () => {};
|
|
4449
|
-
return commitEmitter.register(listener);
|
|
4450
|
-
},
|
|
4451
|
-
onSettled(handler) {
|
|
4452
|
-
return settleEmitter.register(handler);
|
|
4453
|
-
}
|
|
4454
|
-
};
|
|
4455
|
-
let activeBoxDrag = null;
|
|
4456
|
-
const onDriverDown = (pixel, info) => {
|
|
4457
|
-
if (ctx.settled || dragInFlight) return;
|
|
4458
|
-
if (!groupBox) return;
|
|
4459
|
-
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
4460
|
-
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
4461
|
-
if (!boxHit) return;
|
|
4462
|
-
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
4463
|
-
if (boxHit.kind === "rotate") {
|
|
4464
|
-
freezeOrientationForRotate();
|
|
4465
|
-
renderAllPreviews();
|
|
4466
|
-
renderHandlesLayer();
|
|
4467
|
-
}
|
|
4468
|
-
const preDragPoints = concatWorkingPoints();
|
|
4469
|
-
const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4470
|
-
activeBoxDrag = {
|
|
4471
|
-
fsm: beginBoxDrag({
|
|
4472
|
-
kind: boxHit.kind,
|
|
4473
|
-
downPixel: pixel,
|
|
4474
|
-
points: preDragPoints,
|
|
4475
|
-
projection: dragProjection,
|
|
4476
|
-
box: groupBox,
|
|
4477
|
-
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
4478
|
-
}),
|
|
4479
|
-
preDragPoints,
|
|
4480
|
-
preDragBox: groupBox,
|
|
4481
|
-
downPixel: pixel,
|
|
4482
|
-
lastSnapshot: {
|
|
4483
|
-
points: preDragPoints,
|
|
4484
|
-
box: groupBox
|
|
4485
|
-
},
|
|
4486
|
-
preDragOptionsOverrideByMember,
|
|
4487
|
-
preDragAmplifierPlacementsByMember
|
|
4488
|
-
};
|
|
4489
|
-
dragInFlight = true;
|
|
4490
|
-
driver.setDragPanEnabled(false);
|
|
4491
|
-
};
|
|
4492
|
-
function paintBoxDragSnapshot(pixel, constrainRotation = false) {
|
|
4493
|
-
if (!activeBoxDrag) return;
|
|
4494
|
-
const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
|
|
4495
|
-
activeBoxDrag.lastSnapshot = snapshot;
|
|
4496
|
-
applyConcatenatedPoints(snapshot.points);
|
|
4497
|
-
applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
|
|
4498
|
-
renderAllPreviews();
|
|
4499
|
-
renderHandlesLayer();
|
|
4500
|
-
recomputeDirty();
|
|
4501
|
-
}
|
|
4502
|
-
const onDriverMove = (pixel, info) => {
|
|
4503
|
-
if (ctx.settled) return;
|
|
4504
|
-
if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
|
|
4505
|
-
};
|
|
4506
|
-
const onDriverUp = (pixel, info) => {
|
|
4507
|
-
if (!activeBoxDrag) return;
|
|
4508
|
-
if (ctx.settled) {
|
|
4509
|
-
activeBoxDrag = null;
|
|
4510
|
-
dragInFlight = false;
|
|
4511
|
-
driver.setDragPanEnabled(true);
|
|
4512
|
-
return;
|
|
4513
|
-
}
|
|
4514
|
-
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember } = activeBoxDrag;
|
|
4515
|
-
activeBoxDrag = null;
|
|
4516
|
-
driver.setDragPanEnabled(true);
|
|
4517
|
-
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
4518
|
-
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$2) {
|
|
4519
|
-
applyConcatenatedPoints(preDragPoints);
|
|
4520
|
-
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4521
|
-
renderAllPreviews();
|
|
4522
|
-
renderHandlesLayer();
|
|
4523
|
-
recomputeDirty();
|
|
4524
|
-
dragInFlight = false;
|
|
4525
|
-
bodyClickPending = true;
|
|
4526
|
-
return;
|
|
4527
|
-
}
|
|
4528
|
-
const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
|
|
4529
|
-
applyConcatenatedPoints(preDragPoints);
|
|
4530
|
-
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4531
|
-
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
4532
|
-
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle);
|
|
4533
|
-
};
|
|
4534
|
-
let pickPreview;
|
|
4535
|
-
let pickHandles;
|
|
4536
|
-
let pickGraphics;
|
|
4537
|
-
const handlePickPreview = (e) => {
|
|
4538
|
-
pickPreview = e;
|
|
4539
|
-
};
|
|
4540
|
-
const handlePickHandles = (e) => {
|
|
4541
|
-
pickHandles = e;
|
|
4542
|
-
};
|
|
4543
|
-
const handlePickGraphics = (e) => {
|
|
4544
|
-
pickGraphics = e;
|
|
4545
|
-
};
|
|
4546
|
-
const handleClick = () => {
|
|
4547
|
-
const localPreview = pickPreview;
|
|
4548
|
-
const localHandles = pickHandles;
|
|
4549
|
-
const localGraphics = pickGraphics;
|
|
4550
|
-
const wasBodyClick = bodyClickPending;
|
|
4551
|
-
pickPreview = void 0;
|
|
4552
|
-
pickHandles = void 0;
|
|
4553
|
-
pickGraphics = void 0;
|
|
4554
|
-
bodyClickPending = false;
|
|
4555
|
-
if (ctx.settled) return;
|
|
4556
|
-
if (!closeOnClickAway) return;
|
|
4557
|
-
if (suppressClickAway) {
|
|
4558
|
-
suppressClickAway = false;
|
|
4559
|
-
return;
|
|
4560
|
-
}
|
|
4561
|
-
if (localHandles) return;
|
|
4562
|
-
const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
|
|
4563
|
-
if (localGraphics && !isMember) {
|
|
4564
|
-
commitClose();
|
|
4565
|
-
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
4566
|
-
return;
|
|
4567
|
-
}
|
|
4568
|
-
if (localPreview) return;
|
|
4569
|
-
if (wasBodyClick) return;
|
|
4570
|
-
commitClose();
|
|
4571
|
-
};
|
|
4572
|
-
const core = runInteraction({
|
|
4573
|
-
adapter,
|
|
4574
|
-
signal,
|
|
4575
|
-
layers: [previewLayer, handlesLayer],
|
|
4576
|
-
onViewChange: rescaleOnViewChange,
|
|
4577
|
-
onSettled: (settlement) => {
|
|
4578
|
-
try {
|
|
4579
|
-
history.settle();
|
|
4580
|
-
} finally {
|
|
4581
|
-
if (onSettled) onSettled();
|
|
4582
|
-
if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
|
|
4583
|
-
}
|
|
4584
|
-
},
|
|
4585
|
-
attach(c) {
|
|
4586
|
-
ctx = c;
|
|
4587
|
-
driver = adapter.createEditPointerDriver();
|
|
4588
|
-
for (const member of members) liftMemberFromGraphics(member);
|
|
4589
|
-
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
4590
|
-
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
4591
|
-
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
4592
|
-
const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
|
|
4593
|
-
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
4594
|
-
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
4595
|
-
adapter.on("click", handleClick);
|
|
4596
|
-
c.onTeardown(() => {
|
|
4597
|
-
adapter.off("click", handleClick);
|
|
4598
|
-
unsubPickPreview();
|
|
4599
|
-
unsubPickHandles();
|
|
4600
|
-
unsubPickGraphics();
|
|
4601
|
-
unsubDown();
|
|
4602
|
-
unsubMove();
|
|
4603
|
-
unsubUp();
|
|
4604
|
-
if (activeBoxDrag !== null) {
|
|
4605
|
-
driver.setDragPanEnabled(true);
|
|
4606
|
-
activeBoxDrag = null;
|
|
4607
|
-
dragInFlight = false;
|
|
4608
|
-
}
|
|
4609
|
-
driver.dispose();
|
|
4610
|
-
}, () => {
|
|
4611
|
-
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
4612
|
-
});
|
|
4613
|
-
renderAllPreviews();
|
|
4614
|
-
renderHandlesLayer();
|
|
4615
|
-
recomputeDirty();
|
|
4616
|
-
if (onSession) onSession(session);
|
|
4617
|
-
}
|
|
4618
|
-
});
|
|
4619
|
-
return {
|
|
4620
|
-
promise: core.promise,
|
|
4621
|
-
get settled() {
|
|
4622
|
-
return core.settled;
|
|
4623
|
-
},
|
|
4624
|
-
get graphicIds() {
|
|
4625
|
-
return members.map((m) => m.measure.id);
|
|
4626
|
-
},
|
|
4627
|
-
abort(reason, cause) {
|
|
4628
|
-
core.abort(reason, cause);
|
|
4629
|
-
}
|
|
4630
|
-
};
|
|
4631
|
-
}
|
|
4632
|
-
//#endregion
|
|
4633
|
-
//#region src/tactical-draw/point-symbol-rendering.ts
|
|
4634
|
-
function effectivePointSymbolPixelSize(symbol, resolution) {
|
|
4635
|
-
if (symbol.size.unit === "pixels") return symbol.size.value;
|
|
4636
|
-
if (resolution === void 0 || resolution <= 0) return symbol.size.value;
|
|
4637
|
-
const px = symbol.size.value / resolution;
|
|
4638
|
-
const { minPixels, maxPixels } = symbol.size;
|
|
4639
|
-
return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
|
|
4640
|
-
}
|
|
4641
|
-
function pointSymbolPixelScale(symbol, render, resolution) {
|
|
4642
|
-
if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
|
|
4643
|
-
return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
|
|
4644
|
-
}
|
|
4645
|
-
/** Complete rotated logical footprint for selection and transform chrome. */
|
|
4646
|
-
function pointSymbolFootprint(symbol, render, adapter) {
|
|
4647
|
-
const anchor = adapter.getPixelFromCoordinate(symbol.position);
|
|
4648
|
-
if (!anchor) return null;
|
|
4649
|
-
const scale = pointSymbolPixelScale(symbol, render, adapter.getResolution());
|
|
4650
|
-
const angle = symbol.rotation - adapter.getBearing();
|
|
4651
|
-
const centerOffsetX = (render.width / 2 - render.anchor.x) * scale;
|
|
4652
|
-
const centerOffsetY = (render.height / 2 - render.anchor.y) * scale;
|
|
4653
|
-
const cos = Math.cos(angle);
|
|
4654
|
-
const sin = Math.sin(angle);
|
|
4655
|
-
const box = {
|
|
4656
|
-
center: [anchor[0] + centerOffsetX * cos - centerOffsetY * sin, anchor[1] + centerOffsetX * sin + centerOffsetY * cos],
|
|
4657
|
-
halfWidth: render.width * scale / 2,
|
|
4658
|
-
halfHeight: render.height * scale / 2,
|
|
4659
|
-
angle
|
|
4660
|
-
};
|
|
4661
|
-
return {
|
|
4662
|
-
box,
|
|
4663
|
-
corners: boxCorners(box)
|
|
4664
|
-
};
|
|
4665
|
-
}
|
|
4666
|
-
function renderPointSymbolForAdapter(adapter, capability, symbol) {
|
|
4667
|
-
const render = capability.render(symbol);
|
|
4668
|
-
return {
|
|
4669
|
-
render,
|
|
4670
|
-
resource: {
|
|
4671
|
-
id: render.resourceId,
|
|
4672
|
-
svg: render.svg,
|
|
4673
|
-
width: render.width,
|
|
4674
|
-
height: render.height,
|
|
4675
|
-
anchor: [render.anchor.x, render.anchor.y]
|
|
4676
|
-
},
|
|
4677
|
-
feature: pointSymbolFeature(symbol, render, adapter.getResolution())
|
|
4678
|
-
};
|
|
4679
|
-
}
|
|
4680
|
-
function pointSymbolFeature(symbol, render, resolution) {
|
|
4681
|
-
const groundSizeProperties = symbol.size.unit === "meters" ? {
|
|
4682
|
-
iconSizeMeters: symbol.size.value,
|
|
4683
|
-
iconOctagonSize: render.octagonSize,
|
|
4684
|
-
...boundProperty("iconMinPixels", symbol.size.minPixels),
|
|
4685
|
-
...boundProperty("iconMaxPixels", symbol.size.maxPixels)
|
|
4686
|
-
} : void 0;
|
|
4687
|
-
return {
|
|
4688
|
-
type: "Feature",
|
|
4689
|
-
id: `${symbol.id}:point-symbol`,
|
|
4690
|
-
geometry: {
|
|
4691
|
-
type: "Point",
|
|
4692
|
-
coordinates: [...symbol.position]
|
|
4693
|
-
},
|
|
4694
|
-
properties: {
|
|
4695
|
-
...symbol.properties ?? {},
|
|
4696
|
-
graphicId: symbol.id,
|
|
4697
|
-
graphicKind: "point-symbol",
|
|
4698
|
-
iconId: render.resourceId,
|
|
4699
|
-
iconScale: pointSymbolPixelScale(symbol, render, resolution),
|
|
4700
|
-
iconRotation: symbol.rotation,
|
|
4701
|
-
iconOpacity: symbol.style?.opacity ?? 1,
|
|
4702
|
-
...groundSizeProperties
|
|
4703
|
-
}
|
|
4704
|
-
};
|
|
4705
|
-
}
|
|
4706
|
-
function boundProperty(key, value) {
|
|
4707
|
-
return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
|
|
4708
|
-
}
|
|
4709
|
-
//#endregion
|
|
4710
|
-
//#region src/tactical-draw/graphic-transform-controller.ts
|
|
4711
|
-
const HIT_TOLERANCE_PX$1 = 12;
|
|
4712
|
-
const CLICK_SLOP_PX$1 = 3;
|
|
4713
|
-
function cloneGraphic(graphic) {
|
|
4714
|
-
return isPointSymbol(graphic) ? clonePointSymbol(graphic) : cloneControlMeasure(graphic);
|
|
4715
|
-
}
|
|
4716
|
-
function runGraphicTransform(opts) {
|
|
4717
|
-
const { adapter, capability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
4718
|
-
const original = graphics.map(cloneGraphic);
|
|
4719
|
-
let working = original.map(cloneGraphic);
|
|
4720
|
-
let dirty = false;
|
|
4721
|
-
let boxAngle = 0;
|
|
4722
|
-
let box = null;
|
|
4723
|
-
let ctx;
|
|
4724
|
-
let driver;
|
|
4725
|
-
let drag = null;
|
|
4726
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
4727
|
-
const commitEmitter = createCommitEmitter("GraphicTransformSession.onCommit listener errors");
|
|
4728
|
-
const settleEmitter = createSettleEmitter("GraphicTransformSession.onSettled listener errors");
|
|
4729
|
-
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
4730
|
-
const snapshotsFor = (source) => source.map((graphic) => isPointSymbol(graphic) ? {
|
|
4731
|
-
graphic: clonePointSymbol(graphic),
|
|
4732
|
-
render: capability.render(graphic)
|
|
4733
|
-
} : {
|
|
4734
|
-
graphic: cloneControlMeasure(graphic),
|
|
4735
|
-
render: renderMeasureForAdapter(adapter, graphic, renderOpts)
|
|
4736
|
-
});
|
|
4737
|
-
const snapshots = () => snapshotsFor(working);
|
|
4738
|
-
const captureHistory = () => ({
|
|
4739
|
-
working: working.map(cloneGraphic),
|
|
4740
|
-
boxAngle
|
|
4741
|
-
});
|
|
4742
|
-
const cloneHistory = (value) => ({
|
|
4743
|
-
working: value.working.map(cloneGraphic),
|
|
4744
|
-
boxAngle: value.boxAngle
|
|
4745
|
-
});
|
|
4746
|
-
function renderAll() {
|
|
4747
|
-
if (ctx.settled) return;
|
|
4748
|
-
const features = [];
|
|
4749
|
-
const resources = /* @__PURE__ */ new Map();
|
|
4750
|
-
const pixels = [];
|
|
4751
|
-
const memberBoxes = [];
|
|
4752
|
-
const angle = drag ? drag.liveBox.angle : boxAngle;
|
|
4753
|
-
for (const graphic of working) {
|
|
4754
|
-
const graphicPixels = [];
|
|
4755
|
-
if (isPointSymbol(graphic)) {
|
|
4756
|
-
const plan = renderPointSymbolForAdapter(adapter, capability, graphic);
|
|
4757
|
-
resources.set(plan.resource.id, plan.resource);
|
|
4758
|
-
features.push(plan.feature);
|
|
4759
|
-
const footprint = pointSymbolFootprint(graphic, plan.render, adapter);
|
|
4760
|
-
if (footprint) graphicPixels.push(...footprint.corners);
|
|
4761
|
-
} else {
|
|
4762
|
-
const rendered = renderMeasureForAdapter(adapter, graphic, renderOpts);
|
|
4763
|
-
features.push(...rendered.features);
|
|
4764
|
-
for (const feature of rendered.features) {
|
|
4765
|
-
const positions = [];
|
|
4766
|
-
if (feature.geometry) collectFeaturePositions(feature.geometry, positions);
|
|
4767
|
-
for (const position of positions) {
|
|
4768
|
-
const pixel = driver.toPixel(position);
|
|
4769
|
-
if (pixel) graphicPixels.push(pixel);
|
|
4770
|
-
}
|
|
4771
|
-
}
|
|
4772
|
-
}
|
|
4773
|
-
pixels.push(...graphicPixels);
|
|
4774
|
-
const memberBox = fitTransformBox(graphicPixels, angle, interactionStyle?.boxPadding);
|
|
4775
|
-
if (memberBox) memberBoxes.push([graphic.id, memberBox]);
|
|
4776
|
-
}
|
|
4777
|
-
adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
|
|
4778
|
-
ctx.writeLayer(previewLayer, features);
|
|
4779
|
-
if (!drag) box = fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding);
|
|
4780
|
-
const liveBox = drag?.liveBox ?? box;
|
|
4781
|
-
const handles = [];
|
|
4782
|
-
if (liveBox) {
|
|
4783
|
-
handles.push(boxOutlineFeature(liveBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, groupOutlineStyle(interactionStyle?.boxOutline)));
|
|
4784
|
-
handles.push(...toFeatures(boxToHandles(liveBox, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
|
|
4785
|
-
}
|
|
4786
|
-
handles.push(...memberOutlineFeatures(memberBoxes, (pixel) => driver.fromPixel(pixel), handleIdPrefix, interactionStyle?.boxOutline));
|
|
4787
|
-
ctx.writeLayer(handlesLayer, handles);
|
|
4788
|
-
}
|
|
4789
|
-
function anchorPoints(source) {
|
|
4790
|
-
const points = [];
|
|
4791
|
-
for (const graphic of source) if (isPointSymbol(graphic)) points.push([...graphic.position]);
|
|
4792
|
-
else points.push(...graphic.controlPoints.map((point) => [...point]));
|
|
4793
|
-
return points;
|
|
4794
|
-
}
|
|
4795
|
-
function applyPoints(source, points, scale, rotation) {
|
|
4796
|
-
let offset = 0;
|
|
4797
|
-
return source.map((graphic) => {
|
|
4798
|
-
if (isPointSymbol(graphic)) {
|
|
4799
|
-
const next = clonePointSymbol(graphic);
|
|
4800
|
-
next.position = [...points[offset++]];
|
|
4801
|
-
next.size.value = Math.max(.1, next.size.value * scale);
|
|
4802
|
-
next.rotation = normalizeRotation(next.rotation + rotation);
|
|
4803
|
-
return next;
|
|
4576
|
+
nextMembers.push(member);
|
|
4804
4577
|
}
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
const previous = snapshotsFor(previousState.working);
|
|
4814
|
-
const current = snapshots();
|
|
4815
|
-
let reject = false;
|
|
4816
|
-
let close = false;
|
|
4817
|
-
let abort = false;
|
|
4818
|
-
const event = {
|
|
4819
|
-
source,
|
|
4820
|
-
graphics: current,
|
|
4821
|
-
previous,
|
|
4822
|
-
session,
|
|
4823
|
-
reject: () => reject = true,
|
|
4824
|
-
close: () => close = true,
|
|
4825
|
-
abort: () => abort = true
|
|
4826
|
-
};
|
|
4827
|
-
const errors = [];
|
|
4828
|
-
for (const listener of [...listeners]) try {
|
|
4829
|
-
listener(event);
|
|
4578
|
+
}
|
|
4579
|
+
members = nextMembers;
|
|
4580
|
+
renderAllPreviews();
|
|
4581
|
+
renderHandlesLayer();
|
|
4582
|
+
recomputeDirty();
|
|
4583
|
+
const notificationErrors = [];
|
|
4584
|
+
try {
|
|
4585
|
+
history.reset(captureHistory());
|
|
4830
4586
|
} catch (error) {
|
|
4831
|
-
|
|
4587
|
+
notificationErrors.push(error);
|
|
4832
4588
|
}
|
|
4833
|
-
if (
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
boxAngle = previousState.boxAngle;
|
|
4838
|
-
dirty = JSON.stringify(working) !== JSON.stringify(original);
|
|
4839
|
-
renderAll();
|
|
4589
|
+
if (removedSnapshots.length > 0) try {
|
|
4590
|
+
commitEmitter.emit(removedSnapshots);
|
|
4591
|
+
} catch (error) {
|
|
4592
|
+
notificationErrors.push(error);
|
|
4840
4593
|
}
|
|
4841
|
-
if (
|
|
4842
|
-
if (
|
|
4843
|
-
return
|
|
4594
|
+
if (notificationErrors.length === 1) throw notificationErrors[0];
|
|
4595
|
+
if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
|
|
4596
|
+
return removedSnapshots;
|
|
4597
|
+
}
|
|
4598
|
+
function close() {
|
|
4599
|
+
return commitClose();
|
|
4844
4600
|
}
|
|
4845
4601
|
const history = createSessionHistory({
|
|
4846
4602
|
initial: captureHistory(),
|
|
4847
|
-
clone:
|
|
4848
|
-
equals: (a, b) =>
|
|
4849
|
-
isCurrent: (value) =>
|
|
4603
|
+
clone: (value) => structuredClone(value),
|
|
4604
|
+
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
4605
|
+
isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
|
|
4850
4606
|
apply(from, to, source) {
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4607
|
+
if (JSON.stringify(from.ids) !== JSON.stringify(to.ids)) return false;
|
|
4608
|
+
const previousRendered = rebuildRenderedAll();
|
|
4609
|
+
restoreHistory(to);
|
|
4610
|
+
renderAllPreviews();
|
|
4611
|
+
renderHandlesLayer();
|
|
4612
|
+
recomputeDirty();
|
|
4613
|
+
emitChange(previousRendered, from.points, from.amplifierPlacements, from.sizeAnchor, from.nonGeometryDirty, from.optionsOverrides, rebuildRenderedAll(), source, "Transform", from.boxAngle, from.pointSymbolStates);
|
|
4614
|
+
return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
|
|
4856
4615
|
}
|
|
4857
4616
|
});
|
|
4858
4617
|
const session = {
|
|
4859
4618
|
history,
|
|
4860
4619
|
get graphics() {
|
|
4861
|
-
return
|
|
4620
|
+
return members.map((m) => m.graphic);
|
|
4862
4621
|
},
|
|
4863
4622
|
get workingGraphics() {
|
|
4864
|
-
return
|
|
4623
|
+
return members.map((_, i) => workingGraphicFor(i));
|
|
4624
|
+
},
|
|
4625
|
+
controlPointsFor(graphicId) {
|
|
4626
|
+
const member = members.find((m) => m.graphic.id === graphicId);
|
|
4627
|
+
if (!member || isPointSymbolMember(member)) return void 0;
|
|
4628
|
+
return member.workingPoints.map((p) => [...p]);
|
|
4865
4629
|
},
|
|
4866
4630
|
get dirty() {
|
|
4867
4631
|
return dirty;
|
|
4868
4632
|
},
|
|
4869
|
-
|
|
4870
|
-
|
|
4633
|
+
get sizeAnchor() {
|
|
4634
|
+
return sizeAnchor;
|
|
4635
|
+
},
|
|
4636
|
+
setSizeAnchor(next) {
|
|
4637
|
+
setSizeAnchor(next);
|
|
4638
|
+
},
|
|
4639
|
+
setGraphics,
|
|
4640
|
+
close,
|
|
4641
|
+
abort() {
|
|
4642
|
+
if (ctx.settled) return;
|
|
4643
|
+
ctx.fail(new TacticalDrawAbortError("session"));
|
|
4644
|
+
},
|
|
4871
4645
|
onChange(listener) {
|
|
4872
4646
|
if (ctx.settled) return () => {};
|
|
4873
|
-
|
|
4874
|
-
return () => listeners.delete(listener);
|
|
4647
|
+
return changeEmitter.register(listener);
|
|
4875
4648
|
},
|
|
4876
4649
|
onCommit(listener) {
|
|
4877
4650
|
if (ctx.settled) return () => {};
|
|
4878
4651
|
return commitEmitter.register(listener);
|
|
4879
4652
|
},
|
|
4880
|
-
onSettled
|
|
4653
|
+
onSettled(handler) {
|
|
4654
|
+
return settleEmitter.register(handler);
|
|
4655
|
+
}
|
|
4881
4656
|
};
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
if (!
|
|
4886
|
-
const
|
|
4887
|
-
|
|
4657
|
+
let activeBoxDrag = null;
|
|
4658
|
+
/** Working point-symbol state for the given in-flight box, from each member's pre-drag state. */
|
|
4659
|
+
function pointSymbolStateForBox(box) {
|
|
4660
|
+
if (!activeBoxDrag) return clonePointSymbolStateByMember();
|
|
4661
|
+
const { scale, rotation } = boxScaleRotation(activeBoxDrag.fsm.kind, box, activeBoxDrag.preDragBox);
|
|
4662
|
+
return scalePointSymbolStates(activeBoxDrag.preDragPointSymbolStateByMember, scale, rotation);
|
|
4663
|
+
}
|
|
4664
|
+
const onDriverDown = (pixel, info) => {
|
|
4665
|
+
if (ctx.settled || dragInFlight) return;
|
|
4666
|
+
if (!groupBox) return;
|
|
4667
|
+
const tol = info?.hitTolerance ?? 12;
|
|
4668
|
+
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
4669
|
+
if (!boxHit) return;
|
|
4670
|
+
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
4671
|
+
if (boxHit.kind === "rotate") {
|
|
4672
|
+
freezeOrientationForRotate();
|
|
4673
|
+
renderAllPreviews();
|
|
4674
|
+
renderHandlesLayer();
|
|
4675
|
+
}
|
|
4676
|
+
const preDragPoints = concatWorkingPoints();
|
|
4677
|
+
const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4678
|
+
const preDragPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4679
|
+
activeBoxDrag = {
|
|
4888
4680
|
fsm: beginBoxDrag({
|
|
4889
|
-
kind:
|
|
4681
|
+
kind: boxHit.kind,
|
|
4890
4682
|
downPixel: pixel,
|
|
4891
|
-
points:
|
|
4892
|
-
projection:
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
},
|
|
4896
|
-
box,
|
|
4897
|
-
cornerIndex: hit.kind === "scale" ? hit.index : void 0
|
|
4683
|
+
points: preDragPoints,
|
|
4684
|
+
projection: dragProjection,
|
|
4685
|
+
box: groupBox,
|
|
4686
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
4898
4687
|
}),
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4688
|
+
preDragPoints,
|
|
4689
|
+
preDragBox: groupBox,
|
|
4690
|
+
downPixel: pixel,
|
|
4691
|
+
lastSnapshot: {
|
|
4692
|
+
points: preDragPoints,
|
|
4693
|
+
box: groupBox
|
|
4694
|
+
},
|
|
4695
|
+
preDragOptionsOverrideByMember,
|
|
4696
|
+
preDragAmplifierPlacementsByMember,
|
|
4697
|
+
preDragPointSymbolStateByMember
|
|
4903
4698
|
};
|
|
4699
|
+
dragInFlight = true;
|
|
4904
4700
|
driver.setDragPanEnabled(false);
|
|
4905
4701
|
};
|
|
4906
|
-
|
|
4907
|
-
if (!
|
|
4908
|
-
const
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
if (
|
|
4920
|
-
working = next.graphics;
|
|
4921
|
-
active.liveBox = next.result.box;
|
|
4922
|
-
renderAll();
|
|
4702
|
+
function paintBoxDragSnapshot(pixel, constrainRotation = false) {
|
|
4703
|
+
if (!activeBoxDrag) return;
|
|
4704
|
+
const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
|
|
4705
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
4706
|
+
applyConcatenatedPoints(snapshot.points);
|
|
4707
|
+
applyPointSymbolStateByMember(pointSymbolStateForBox(snapshot.box));
|
|
4708
|
+
applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
|
|
4709
|
+
renderAllPreviews();
|
|
4710
|
+
renderHandlesLayer();
|
|
4711
|
+
recomputeDirty();
|
|
4712
|
+
}
|
|
4713
|
+
const onDriverMove = (pixel, info) => {
|
|
4714
|
+
if (ctx.settled) return;
|
|
4715
|
+
if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
|
|
4923
4716
|
};
|
|
4924
|
-
const
|
|
4925
|
-
if (!
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
driver.setDragPanEnabled(true);
|
|
4931
|
-
working = active.start.map(cloneGraphic);
|
|
4932
|
-
if (!next) return;
|
|
4933
|
-
if (active.fsm.kind === "body" && Math.hypot(pixel[0] - active.down[0], pixel[1] - active.down[1]) <= CLICK_SLOP_PX$1) {
|
|
4934
|
-
renderAll();
|
|
4717
|
+
const onDriverUp = (pixel, info) => {
|
|
4718
|
+
if (!activeBoxDrag) return;
|
|
4719
|
+
if (ctx.settled) {
|
|
4720
|
+
activeBoxDrag = null;
|
|
4721
|
+
dragInFlight = false;
|
|
4722
|
+
driver.setDragPanEnabled(true);
|
|
4935
4723
|
return;
|
|
4936
4724
|
}
|
|
4937
|
-
const
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
let graphicsPick;
|
|
4950
|
-
let suppressClickAway = false;
|
|
4951
|
-
const onClick = () => {
|
|
4952
|
-
const keepOpen = previewPick || handlesPick;
|
|
4953
|
-
const deferred = graphicsPick;
|
|
4954
|
-
previewPick = handlesPick = graphicsPick = void 0;
|
|
4955
|
-
if (suppressClickAway) {
|
|
4956
|
-
suppressClickAway = false;
|
|
4725
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember, preDragPointSymbolStateByMember } = activeBoxDrag;
|
|
4726
|
+
activeBoxDrag = null;
|
|
4727
|
+
driver.setDragPanEnabled(true);
|
|
4728
|
+
if (fsm.kind === "body" && withinClickSlop(pixel, downPixel)) {
|
|
4729
|
+
applyConcatenatedPoints(preDragPoints);
|
|
4730
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4731
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4732
|
+
renderAllPreviews();
|
|
4733
|
+
renderHandlesLayer();
|
|
4734
|
+
recomputeDirty();
|
|
4735
|
+
dragInFlight = false;
|
|
4736
|
+
shell.noteBodyClick();
|
|
4957
4737
|
return;
|
|
4958
4738
|
}
|
|
4959
|
-
|
|
4960
|
-
|
|
4739
|
+
const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
|
|
4740
|
+
const { scale, rotation } = boxScaleRotation(fsm.kind, finalSnapshot.box, preDragBox);
|
|
4741
|
+
const nextPointSymbolStateByMember = scalePointSymbolStates(preDragPointSymbolStateByMember, scale, rotation);
|
|
4742
|
+
applyConcatenatedPoints(preDragPoints);
|
|
4743
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4744
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4745
|
+
if (members.some(isPointSymbolMember)) shell.suppressNextClickAway();
|
|
4746
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
4747
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle, nextPointSymbolStateByMember);
|
|
4961
4748
|
};
|
|
4962
|
-
const core =
|
|
4749
|
+
const core = runEditInteraction({
|
|
4963
4750
|
adapter,
|
|
4964
4751
|
signal,
|
|
4965
4752
|
layers: [previewLayer, handlesLayer],
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4753
|
+
graphicsLayer,
|
|
4754
|
+
previewLayer,
|
|
4755
|
+
handlesLayer,
|
|
4756
|
+
closeOnClickAway,
|
|
4757
|
+
onViewChange: rescaleOnViewChange,
|
|
4758
|
+
onDown: onDriverDown,
|
|
4759
|
+
onMove: onDriverMove,
|
|
4760
|
+
onUp: onDriverUp,
|
|
4761
|
+
ownsGraphic: (id) => members.some((m) => m.graphic.id === id),
|
|
4762
|
+
close: () => {
|
|
4763
|
+
commitClose();
|
|
4764
|
+
},
|
|
4765
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
4766
|
+
historySettle: () => history.settle(),
|
|
4767
|
+
facadeOnSettled: onSettled,
|
|
4768
|
+
emitSettle: (settlement) => {
|
|
4769
|
+
if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
|
|
4770
|
+
},
|
|
4771
|
+
extraTeardown: () => {
|
|
4772
|
+
if (activeBoxDrag !== null) {
|
|
4773
|
+
driver.setDragPanEnabled(true);
|
|
4774
|
+
activeBoxDrag = null;
|
|
4775
|
+
dragInFlight = false;
|
|
4973
4776
|
}
|
|
4777
|
+
adapter.reconcileLayerIconResources(previewLayer, []);
|
|
4974
4778
|
},
|
|
4975
|
-
|
|
4779
|
+
onAbort: () => {
|
|
4780
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
4781
|
+
},
|
|
4782
|
+
attach(interaction, createdDriver, createdShell) {
|
|
4976
4783
|
ctx = interaction;
|
|
4977
|
-
driver =
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
adapter.onPick(graphicsLayer, (event) => graphicsPick = event)
|
|
4985
|
-
];
|
|
4986
|
-
interaction.onTeardown(() => {
|
|
4987
|
-
unsubs.forEach((unsubscribe) => unsubscribe());
|
|
4988
|
-
adapter.off("click", onClick);
|
|
4989
|
-
driver.dispose();
|
|
4990
|
-
adapter.reconcileLayerIconResources(previewLayer, []);
|
|
4991
|
-
}, restoreOriginalToGraphics);
|
|
4992
|
-
for (const graphic of working) {
|
|
4993
|
-
if (!isOnGraphicsLayer(graphic.id)) continue;
|
|
4994
|
-
if (isPointSymbol(graphic)) adapter.removeFeatures(graphicsLayer, [`${graphic.id}:point-symbol`]);
|
|
4995
|
-
else {
|
|
4996
|
-
const ids = renderMeasureForAdapter(adapter, graphic, renderOpts).features.map((feature) => feature.id).filter((id) => typeof id === "string");
|
|
4997
|
-
adapter.removeFeatures(graphicsLayer, ids);
|
|
4998
|
-
}
|
|
4999
|
-
}
|
|
5000
|
-
adapter.on("click", onClick);
|
|
5001
|
-
renderAll();
|
|
5002
|
-
onSession?.(session);
|
|
4784
|
+
driver = createdDriver;
|
|
4785
|
+
shell = createdShell;
|
|
4786
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
4787
|
+
renderAllPreviews();
|
|
4788
|
+
renderHandlesLayer();
|
|
4789
|
+
recomputeDirty();
|
|
4790
|
+
if (onSession) onSession(session);
|
|
5003
4791
|
}
|
|
5004
4792
|
});
|
|
5005
4793
|
return {
|
|
@@ -5008,31 +4796,14 @@ function runGraphicTransform(opts) {
|
|
|
5008
4796
|
return core.settled;
|
|
5009
4797
|
},
|
|
5010
4798
|
get graphicIds() {
|
|
5011
|
-
return
|
|
4799
|
+
return members.map((m) => m.graphic.id);
|
|
5012
4800
|
},
|
|
5013
|
-
abort
|
|
4801
|
+
abort(reason, cause) {
|
|
4802
|
+
core.abort(reason, cause);
|
|
4803
|
+
}
|
|
5014
4804
|
};
|
|
5015
4805
|
}
|
|
5016
4806
|
//#endregion
|
|
5017
|
-
//#region src/tactical-draw/errors.ts
|
|
5018
|
-
/**
|
|
5019
|
-
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
5020
|
-
* See PRD #40 / issue #45.
|
|
5021
|
-
*/
|
|
5022
|
-
var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
|
|
5023
|
-
constructor(message = "TacticalDraw instance has been destroyed") {
|
|
5024
|
-
super(message);
|
|
5025
|
-
this.name = "TacticalDrawDestroyedError";
|
|
5026
|
-
Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
|
|
5027
|
-
}
|
|
5028
|
-
};
|
|
5029
|
-
var PointSymbolCapabilityRequiredError = class extends Error {
|
|
5030
|
-
constructor() {
|
|
5031
|
-
super("TacticalDraw: point-symbol capability is not configured");
|
|
5032
|
-
this.name = "PointSymbolCapabilityRequiredError";
|
|
5033
|
-
}
|
|
5034
|
-
};
|
|
5035
|
-
//#endregion
|
|
5036
4807
|
//#region src/tactical-draw/point-symbol-draw-controller.ts
|
|
5037
4808
|
const PREVIEW_ID = "__td_point_symbol_preview__";
|
|
5038
4809
|
function runPointSymbolDraw(options) {
|
|
@@ -5092,8 +4863,6 @@ function runPointSymbolDraw(options) {
|
|
|
5092
4863
|
}
|
|
5093
4864
|
//#endregion
|
|
5094
4865
|
//#region src/tactical-draw/point-symbol-edit-controller.ts
|
|
5095
|
-
const HIT_TOLERANCE_PX = 12;
|
|
5096
|
-
const CLICK_SLOP_PX = 3;
|
|
5097
4866
|
function normalisePointModes(input) {
|
|
5098
4867
|
const modes = input ?? ["transform"];
|
|
5099
4868
|
for (const mode of modes) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
|
|
@@ -5117,7 +4886,13 @@ function runPointSymbolEdit(opts) {
|
|
|
5117
4886
|
let drag = null;
|
|
5118
4887
|
let ctx;
|
|
5119
4888
|
let driver;
|
|
5120
|
-
|
|
4889
|
+
let shell;
|
|
4890
|
+
const changeEmitter = createChangeEmitter({
|
|
4891
|
+
listenerErrorsMessage: "GraphicEditSession.onChange errors",
|
|
4892
|
+
isSettled: () => ctx.settled,
|
|
4893
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
4894
|
+
close: () => ctx.commit(snapshot)
|
|
4895
|
+
});
|
|
5121
4896
|
const commitEmitter = createCommitEmitter("GraphicEditSession.onCommit listener errors");
|
|
5122
4897
|
const settleEmitter = createSettleEmitter("GraphicEditSession.onSettled listener errors");
|
|
5123
4898
|
const captureHistory = () => ({ working: clonePointSymbol(working) });
|
|
@@ -5139,46 +4914,24 @@ function runPointSymbolEdit(opts) {
|
|
|
5139
4914
|
}
|
|
5140
4915
|
ctx.writeLayer(handlesLayer, handles);
|
|
5141
4916
|
};
|
|
5142
|
-
const emitChange = (previousState, source) => {
|
|
4917
|
+
const emitChange = (previousState, source, onAccepted) => {
|
|
5143
4918
|
const previous = {
|
|
5144
4919
|
graphic: clonePointSymbol(previousState.working),
|
|
5145
4920
|
render: capability.render(previousState.working)
|
|
5146
4921
|
};
|
|
5147
|
-
|
|
5148
|
-
let reject = false;
|
|
5149
|
-
let close = false;
|
|
5150
|
-
let abort = false;
|
|
5151
|
-
const event = {
|
|
4922
|
+
return changeEmitter.emit({
|
|
5152
4923
|
source,
|
|
5153
|
-
graphic:
|
|
4924
|
+
graphic: snapshot(),
|
|
5154
4925
|
previous,
|
|
5155
|
-
session
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
4926
|
+
session
|
|
4927
|
+
}, {
|
|
4928
|
+
rollback() {
|
|
4929
|
+
working = clonePointSymbol(previousState.working);
|
|
4930
|
+
dirty = JSON.stringify(working) !== JSON.stringify(symbol);
|
|
4931
|
+
render();
|
|
5161
4932
|
},
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
}
|
|
5165
|
-
};
|
|
5166
|
-
const errors = [];
|
|
5167
|
-
for (const listener of [...listeners]) try {
|
|
5168
|
-
listener(event);
|
|
5169
|
-
} catch (error) {
|
|
5170
|
-
errors.push(error);
|
|
5171
|
-
}
|
|
5172
|
-
if (abort) ctx.fail(new TacticalDrawAbortError("session"));
|
|
5173
|
-
else if (close) ctx.commit(snapshot);
|
|
5174
|
-
else if (reject && !ctx.settled) {
|
|
5175
|
-
working = clonePointSymbol(previousState.working);
|
|
5176
|
-
dirty = JSON.stringify(working) !== JSON.stringify(symbol);
|
|
5177
|
-
render();
|
|
5178
|
-
}
|
|
5179
|
-
if (errors.length === 1) throw errors[0];
|
|
5180
|
-
if (errors.length > 1) throw new AggregateError(errors, "GraphicEditSession.onChange errors");
|
|
5181
|
-
return !ctx.settled && !reject;
|
|
4933
|
+
onAccepted
|
|
4934
|
+
});
|
|
5182
4935
|
};
|
|
5183
4936
|
const history = createSessionHistory({
|
|
5184
4937
|
initial: captureHistory(),
|
|
@@ -5197,7 +4950,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5197
4950
|
working = clonePointSymbol(next);
|
|
5198
4951
|
dirty = JSON.stringify(working) !== JSON.stringify(symbol);
|
|
5199
4952
|
render();
|
|
5200
|
-
|
|
4953
|
+
emitChange(previousState, source, () => history.record(captureHistory(), label));
|
|
5201
4954
|
};
|
|
5202
4955
|
const session = {
|
|
5203
4956
|
history,
|
|
@@ -5231,8 +4984,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5231
4984
|
},
|
|
5232
4985
|
onChange(listener) {
|
|
5233
4986
|
if (ctx.settled) return () => {};
|
|
5234
|
-
|
|
5235
|
-
return () => listeners.delete(listener);
|
|
4987
|
+
return changeEmitter.register(listener);
|
|
5236
4988
|
},
|
|
5237
4989
|
onCommit(listener) {
|
|
5238
4990
|
if (ctx.settled) return () => {};
|
|
@@ -5245,7 +4997,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5245
4997
|
const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
5246
4998
|
const onDown = (pixel, info) => {
|
|
5247
4999
|
if (ctx.settled || !box || !modes.includes("transform")) return;
|
|
5248
|
-
const hit = pickBoxHandle(box, pixel, info?.hitTolerance ??
|
|
5000
|
+
const hit = pickBoxHandle(box, pixel, info?.hitTolerance ?? 12);
|
|
5249
5001
|
if (!hit) return;
|
|
5250
5002
|
const anchor = driver.toPixel(working.position);
|
|
5251
5003
|
if (!anchor) return;
|
|
@@ -5277,7 +5029,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5277
5029
|
next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
|
|
5278
5030
|
} else {
|
|
5279
5031
|
const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
|
|
5280
|
-
next.size
|
|
5032
|
+
next.size = resizePointSymbolSize(drag.start.size, Math.max(.1, drag.start.size.value * (distance / drag.startDistance)));
|
|
5281
5033
|
}
|
|
5282
5034
|
return next;
|
|
5283
5035
|
};
|
|
@@ -5292,68 +5044,45 @@ function runPointSymbolEdit(opts) {
|
|
|
5292
5044
|
const currentDrag = drag;
|
|
5293
5045
|
const next = transformed(pixel, info);
|
|
5294
5046
|
drag = null;
|
|
5295
|
-
|
|
5047
|
+
shell.suppressNextClickAway();
|
|
5296
5048
|
driver.setDragPanEnabled(true);
|
|
5297
5049
|
working = clonePointSymbol(currentDrag.start);
|
|
5298
5050
|
if (!next) return;
|
|
5299
|
-
if (currentDrag.kind === "body" &&
|
|
5051
|
+
if (currentDrag.kind === "body" && withinClickSlop(pixel, currentDrag.down)) {
|
|
5300
5052
|
render();
|
|
5301
5053
|
return;
|
|
5302
5054
|
}
|
|
5303
5055
|
apply(next, "gesture", `${currentDrag.kind[0].toUpperCase()}${currentDrag.kind.slice(1)}`);
|
|
5304
5056
|
};
|
|
5305
|
-
|
|
5306
|
-
let handlesPick;
|
|
5307
|
-
let graphicsPick;
|
|
5308
|
-
let suppressClickAway = false;
|
|
5309
|
-
const onPreviewPick = (event) => previewPick = event;
|
|
5310
|
-
const onHandlesPick = (event) => handlesPick = event;
|
|
5311
|
-
const onGraphicsPick = (event) => graphicsPick = event;
|
|
5312
|
-
const onClick = () => {
|
|
5313
|
-
const keepOpen = previewPick || handlesPick;
|
|
5314
|
-
const deferred = graphicsPick;
|
|
5315
|
-
previewPick = handlesPick = graphicsPick = void 0;
|
|
5316
|
-
if (suppressClickAway) {
|
|
5317
|
-
suppressClickAway = false;
|
|
5318
|
-
return;
|
|
5319
|
-
}
|
|
5320
|
-
if (!closeOnClickAway || keepOpen || drag) return;
|
|
5321
|
-
if (ctx.commit(snapshot) && deferred) onClickAwayGraphicsPick?.(deferred);
|
|
5322
|
-
};
|
|
5323
|
-
const handle = runInteraction({
|
|
5057
|
+
const handle = runEditInteraction({
|
|
5324
5058
|
adapter,
|
|
5325
5059
|
signal,
|
|
5326
5060
|
layers: [previewLayer, handlesLayer],
|
|
5061
|
+
graphicsLayer,
|
|
5062
|
+
previewLayer,
|
|
5063
|
+
handlesLayer,
|
|
5064
|
+
closeOnClickAway,
|
|
5327
5065
|
onViewChange: render,
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
}
|
|
5066
|
+
onDown,
|
|
5067
|
+
onMove,
|
|
5068
|
+
onUp,
|
|
5069
|
+
ownsGraphic: (id) => id === symbol.id,
|
|
5070
|
+
close: () => {
|
|
5071
|
+
ctx.commit(snapshot);
|
|
5335
5072
|
},
|
|
5336
|
-
|
|
5073
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
5074
|
+
historySettle: () => history.settle(),
|
|
5075
|
+
facadeOnSettled: onSettled,
|
|
5076
|
+
emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
|
|
5077
|
+
extraTeardown: () => adapter.reconcileLayerIconResources(previewLayer, []),
|
|
5078
|
+
onAbort: () => {
|
|
5079
|
+
if (wasOnGraphicsLayer) restoreOriginalToGraphics();
|
|
5080
|
+
},
|
|
5081
|
+
attach(interaction, createdDriver, createdShell) {
|
|
5337
5082
|
ctx = interaction;
|
|
5338
|
-
driver =
|
|
5339
|
-
|
|
5340
|
-
driver.onPointerDown(onDown),
|
|
5341
|
-
driver.onPointerMove(onMove),
|
|
5342
|
-
driver.onPointerUp(onUp),
|
|
5343
|
-
adapter.onPick(previewLayer, onPreviewPick),
|
|
5344
|
-
adapter.onPick(handlesLayer, onHandlesPick),
|
|
5345
|
-
adapter.onPick(graphicsLayer, onGraphicsPick)
|
|
5346
|
-
];
|
|
5347
|
-
interaction.onTeardown(() => {
|
|
5348
|
-
unsubs.forEach((unsubscribe) => unsubscribe());
|
|
5349
|
-
adapter.off("click", onClick);
|
|
5350
|
-
driver.dispose();
|
|
5351
|
-
adapter.reconcileLayerIconResources(previewLayer, []);
|
|
5352
|
-
}, () => {
|
|
5353
|
-
if (wasOnGraphicsLayer) restoreOriginalToGraphics();
|
|
5354
|
-
});
|
|
5083
|
+
driver = createdDriver;
|
|
5084
|
+
shell = createdShell;
|
|
5355
5085
|
if (wasOnGraphicsLayer) adapter.removeFeatures(graphicsLayer, [`${symbol.id}:point-symbol`]);
|
|
5356
|
-
adapter.on("click", onClick);
|
|
5357
5086
|
render();
|
|
5358
5087
|
onSession?.(session);
|
|
5359
5088
|
}
|
|
@@ -5848,8 +5577,7 @@ var TacticalDraw = class {
|
|
|
5848
5577
|
if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate graphic id "${m.id}"`);
|
|
5849
5578
|
seenIds.add(m.id);
|
|
5850
5579
|
}
|
|
5851
|
-
|
|
5852
|
-
if (hasPointSymbols && !this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
|
|
5580
|
+
if (measures.some(isPointSymbol) && !this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
|
|
5853
5581
|
const preempt = this.guardAndPreempt();
|
|
5854
5582
|
if (preempt) return preempt;
|
|
5855
5583
|
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
@@ -5864,7 +5592,7 @@ var TacticalDraw = class {
|
|
|
5864
5592
|
const extraSettle = () => {
|
|
5865
5593
|
this.activeTransformSessionRef = null;
|
|
5866
5594
|
};
|
|
5867
|
-
|
|
5595
|
+
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
5868
5596
|
adapter: this.adapter,
|
|
5869
5597
|
capability: this.pointSymbols,
|
|
5870
5598
|
graphics: measures,
|
|
@@ -5877,28 +5605,6 @@ var TacticalDraw = class {
|
|
|
5877
5605
|
interactionStyle: mergedInteractionStyle,
|
|
5878
5606
|
onSession: this.trackTransformSession(options?.onSession),
|
|
5879
5607
|
handleIdPrefix,
|
|
5880
|
-
restoreOriginalToGraphics,
|
|
5881
|
-
isOnGraphicsLayer,
|
|
5882
|
-
onSettled,
|
|
5883
|
-
onClickAwayGraphicsPick
|
|
5884
|
-
}), (handle) => {
|
|
5885
|
-
this.activeTransform = handle;
|
|
5886
|
-
return () => {
|
|
5887
|
-
if (this.activeTransform === handle) this.activeTransform = null;
|
|
5888
|
-
};
|
|
5889
|
-
}, extraSettle);
|
|
5890
|
-
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
5891
|
-
adapter: this.adapter,
|
|
5892
|
-
measures,
|
|
5893
|
-
graphicsLayer: this.graphicsLayer,
|
|
5894
|
-
previewLayer: this.previewLayer,
|
|
5895
|
-
handlesLayer: this.handlesLayer,
|
|
5896
|
-
graphicsStyle: this.graphicsStyle,
|
|
5897
|
-
signal: options?.signal,
|
|
5898
|
-
closeOnClickAway: options?.closeOnClickAway,
|
|
5899
|
-
interactionStyle: mergedInteractionStyle,
|
|
5900
|
-
onSession: this.trackTransformSession(options?.onSession),
|
|
5901
|
-
handleIdPrefix,
|
|
5902
5608
|
sizeAnchor: options?.sizeAnchor,
|
|
5903
5609
|
restoreOriginalToGraphics,
|
|
5904
5610
|
isOnGraphicsLayer,
|
|
@@ -5912,12 +5618,15 @@ var TacticalDraw = class {
|
|
|
5912
5618
|
}, extraSettle);
|
|
5913
5619
|
}
|
|
5914
5620
|
/**
|
|
5915
|
-
* One-call selection-sync: mutate a live
|
|
5916
|
-
*
|
|
5917
|
-
*
|
|
5918
|
-
*
|
|
5919
|
-
*
|
|
5920
|
-
*
|
|
5621
|
+
* One-call selection-sync: mutate a live transform's member set in place, or
|
|
5622
|
+
* start a fresh `editMany` when none is live (#191). Any mix of control
|
|
5623
|
+
* measures and point symbols is handled by the one Graphic-typed session
|
|
5624
|
+
* (ADR-0032): a point symbol joins or leaves the live session exactly like a
|
|
5625
|
+
* measure via `setGraphics`, preserving box angle, size anchor, and history
|
|
5626
|
+
* baseline — no preempt-and-restart. A point symbol in the selection with no
|
|
5627
|
+
* point-symbol capability configured throws `PointSymbolCapabilityRequiredError`
|
|
5628
|
+
* synchronously (from `setGraphics` on a live session, or from `editMany` on a
|
|
5629
|
+
* fresh one).
|
|
5921
5630
|
*
|
|
5922
5631
|
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
5923
5632
|
* is cleared inside the settle path, before control returns to host code — so
|
|
@@ -5946,7 +5655,7 @@ var TacticalDraw = class {
|
|
|
5946
5655
|
syncTransformGraphics(graphics, options) {
|
|
5947
5656
|
this.assertAlive();
|
|
5948
5657
|
const session = this.activeTransformSessionRef;
|
|
5949
|
-
if (session
|
|
5658
|
+
if (session) {
|
|
5950
5659
|
if (session.setGraphics(graphics) !== void 0) return;
|
|
5951
5660
|
}
|
|
5952
5661
|
if (graphics.length === 0) {
|
|
@@ -6350,4 +6059,4 @@ function nearestWorldCopyPosition(position, refLng) {
|
|
|
6350
6059
|
return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
|
|
6351
6060
|
}
|
|
6352
6061
|
//#endregion
|
|
6353
|
-
export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
|
|
6062
|
+
export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
|