@orbat-mapper/tactical-draw 0.2.0-alpha.24 → 0.2.0-alpha.25
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 +80 -69
- package/dist/index.mjs +1210 -1520
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1427,6 +1427,71 @@ function createSettleEmitter(listenerErrorsMessage) {
|
|
|
1427
1427
|
};
|
|
1428
1428
|
}
|
|
1429
1429
|
/**
|
|
1430
|
+
* The deferred reject/close/abort change-emit protocol (issue #49 contract)
|
|
1431
|
+
* shared by `EditSession.onChange`, `TransformSession.onChange`, and
|
|
1432
|
+
* `GraphicEditSession.onChange`. Owns the listener `Set` (like
|
|
1433
|
+
* `createCommitEmitter.register`) and, per emission:
|
|
1434
|
+
*
|
|
1435
|
+
* - builds one event by spreading the controller-supplied `payload` plus the
|
|
1436
|
+
* injected `reject()` / `close()` / `abort()` recorders, and delivers that
|
|
1437
|
+
* same instance to every listener in registration order;
|
|
1438
|
+
* - collects listener exceptions (single → rethrow as-is, multiple →
|
|
1439
|
+
* `AggregateError` with `listenerErrorsMessage`);
|
|
1440
|
+
* - applies decisions after all listeners run, priority `abort` > `close` >
|
|
1441
|
+
* `reject`, with `reject` further gated on `!isSettled()` and
|
|
1442
|
+
* `stillApplicable()`;
|
|
1443
|
+
* - runs `onAccepted` (when the change survived) *before* rethrowing listener
|
|
1444
|
+
* errors — unifying every controller on edit's history-before-throw ordering
|
|
1445
|
+
* — and returns whether the change was accepted.
|
|
1446
|
+
*
|
|
1447
|
+
* Registration-time `isSettled()` guards stay in the controllers' `onChange`
|
|
1448
|
+
* wrappers, mirroring `createCommitEmitter`.
|
|
1449
|
+
*/
|
|
1450
|
+
function createChangeEmitter(options) {
|
|
1451
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1452
|
+
return {
|
|
1453
|
+
register(listener) {
|
|
1454
|
+
listeners.add(listener);
|
|
1455
|
+
return () => {
|
|
1456
|
+
listeners.delete(listener);
|
|
1457
|
+
};
|
|
1458
|
+
},
|
|
1459
|
+
emit(payload, emission) {
|
|
1460
|
+
if (options.isSettled()) return false;
|
|
1461
|
+
let rejectRequested = false;
|
|
1462
|
+
let closeRequested = false;
|
|
1463
|
+
let abortRequested = false;
|
|
1464
|
+
const event = {
|
|
1465
|
+
...payload,
|
|
1466
|
+
reject() {
|
|
1467
|
+
rejectRequested = true;
|
|
1468
|
+
},
|
|
1469
|
+
close() {
|
|
1470
|
+
closeRequested = true;
|
|
1471
|
+
},
|
|
1472
|
+
abort() {
|
|
1473
|
+
abortRequested = true;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
const errors = [];
|
|
1477
|
+
for (const listener of [...listeners]) try {
|
|
1478
|
+
listener(event);
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
errors.push(err);
|
|
1481
|
+
}
|
|
1482
|
+
const applicable = emission.stillApplicable?.() ?? true;
|
|
1483
|
+
if (abortRequested) options.abort();
|
|
1484
|
+
else if (closeRequested) options.close();
|
|
1485
|
+
else if (rejectRequested && !options.isSettled() && applicable) emission.rollback();
|
|
1486
|
+
const accepted = !options.isSettled() && !rejectRequested && applicable;
|
|
1487
|
+
if (accepted) emission.onAccepted?.();
|
|
1488
|
+
if (errors.length === 1) throw errors[0];
|
|
1489
|
+
if (errors.length > 1) throw new AggregateError(errors, options.listenerErrorsMessage);
|
|
1490
|
+
return accepted;
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1430
1495
|
* Subscribe/emit machinery shared by `TransformSession.onCommit`,
|
|
1431
1496
|
* `EditSession.onCommit`, and `DrawSession.onCommit`: a listener set with
|
|
1432
1497
|
* synchronous, exception-collecting emission (single rethrow, `AggregateError`
|
|
@@ -1734,33 +1799,107 @@ function outlinePreview(points) {
|
|
|
1734
1799
|
}
|
|
1735
1800
|
return features;
|
|
1736
1801
|
}
|
|
1802
|
+
/** Whether `b` lands within {@link CLICK_SLOP_PX} of `a` — a click, not a drag. */
|
|
1803
|
+
function withinClickSlop(a, b) {
|
|
1804
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= 3;
|
|
1805
|
+
}
|
|
1806
|
+
function runEditInteraction(spec) {
|
|
1807
|
+
const { adapter, signal, layers, graphicsLayer, previewLayer, handlesLayer, closeOnClickAway } = spec;
|
|
1808
|
+
let ctx;
|
|
1809
|
+
let driver;
|
|
1810
|
+
let previewPick;
|
|
1811
|
+
let handlesPick;
|
|
1812
|
+
let graphicsPick;
|
|
1813
|
+
/**
|
|
1814
|
+
* Consume exactly one trailing click without closing — set through
|
|
1815
|
+
* `shell.suppressNextClickAway()` on delete / label-drag / post-drag paths.
|
|
1816
|
+
*/
|
|
1817
|
+
let suppressClickAway = false;
|
|
1818
|
+
/** A zero-movement body drag released this pointer cycle — set through `shell.noteBodyClick()`. */
|
|
1819
|
+
let bodyClickPending = false;
|
|
1820
|
+
const shell = {
|
|
1821
|
+
suppressNextClickAway() {
|
|
1822
|
+
suppressClickAway = true;
|
|
1823
|
+
},
|
|
1824
|
+
noteBodyClick() {
|
|
1825
|
+
bodyClickPending = true;
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
const onPreviewPick = (event) => {
|
|
1829
|
+
previewPick = event;
|
|
1830
|
+
};
|
|
1831
|
+
const onHandlesPick = (event) => {
|
|
1832
|
+
handlesPick = event;
|
|
1833
|
+
};
|
|
1834
|
+
const onGraphicsPick = (event) => {
|
|
1835
|
+
graphicsPick = event;
|
|
1836
|
+
};
|
|
1837
|
+
const onClick = () => {
|
|
1838
|
+
const localPreview = previewPick;
|
|
1839
|
+
const localHandles = handlesPick;
|
|
1840
|
+
const localGraphics = graphicsPick;
|
|
1841
|
+
const wasBodyClick = bodyClickPending;
|
|
1842
|
+
previewPick = void 0;
|
|
1843
|
+
handlesPick = void 0;
|
|
1844
|
+
graphicsPick = void 0;
|
|
1845
|
+
bodyClickPending = false;
|
|
1846
|
+
if (ctx.settled) return;
|
|
1847
|
+
if (!closeOnClickAway) return;
|
|
1848
|
+
if (suppressClickAway) {
|
|
1849
|
+
suppressClickAway = false;
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (localHandles) return;
|
|
1853
|
+
if (localGraphics && !spec.ownsGraphic(localGraphics.id)) {
|
|
1854
|
+
spec.close();
|
|
1855
|
+
spec.onForeignPick?.(localGraphics);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
if (localPreview) return;
|
|
1859
|
+
if (wasBodyClick) return;
|
|
1860
|
+
spec.close();
|
|
1861
|
+
};
|
|
1862
|
+
return runInteraction({
|
|
1863
|
+
adapter,
|
|
1864
|
+
signal,
|
|
1865
|
+
layers,
|
|
1866
|
+
onViewChange: spec.onViewChange,
|
|
1867
|
+
onSettled: (settlement) => {
|
|
1868
|
+
try {
|
|
1869
|
+
spec.historySettle();
|
|
1870
|
+
} finally {
|
|
1871
|
+
spec.facadeOnSettled?.();
|
|
1872
|
+
spec.emitSettle(settlement);
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
attach(interaction) {
|
|
1876
|
+
ctx = interaction;
|
|
1877
|
+
driver = adapter.createEditPointerDriver();
|
|
1878
|
+
const unsubDown = driver.onPointerDown(spec.onDown);
|
|
1879
|
+
const unsubMove = driver.onPointerMove(spec.onMove);
|
|
1880
|
+
const unsubUp = driver.onPointerUp(spec.onUp);
|
|
1881
|
+
const unsubPreview = adapter.onPick(previewLayer, onPreviewPick);
|
|
1882
|
+
const unsubHandles = adapter.onPick(handlesLayer, onHandlesPick);
|
|
1883
|
+
const unsubGraphics = adapter.onPick(graphicsLayer, onGraphicsPick);
|
|
1884
|
+
adapter.on("click", onClick);
|
|
1885
|
+
interaction.onTeardown(() => {
|
|
1886
|
+
adapter.off("click", onClick);
|
|
1887
|
+
unsubPreview();
|
|
1888
|
+
unsubHandles();
|
|
1889
|
+
unsubGraphics();
|
|
1890
|
+
unsubDown();
|
|
1891
|
+
unsubMove();
|
|
1892
|
+
unsubUp();
|
|
1893
|
+
spec.extraTeardown?.();
|
|
1894
|
+
driver.dispose();
|
|
1895
|
+
}, spec.onAbort);
|
|
1896
|
+
spec.attach(interaction, driver, shell);
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1737
1900
|
//#endregion
|
|
1738
1901
|
//#region src/tactical-draw/handles.ts
|
|
1739
1902
|
/**
|
|
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
1903
|
* Midpoint of the segment between two positions, in lonLat. The single source
|
|
1765
1904
|
* for "where a midpoint handle sits": `buildHandleSet` places the handle here
|
|
1766
1905
|
* and the handle-drag FSM (ADR-0017) inserts the new vertex here, so a grabbed
|
|
@@ -2032,8 +2171,7 @@ function pixelDistance(a, b) {
|
|
|
2032
2171
|
* a lon/lat rotation when the pivot (or a point) cannot be projected. Extra
|
|
2033
2172
|
* coordinate dimensions (z, m) are carried through untouched.
|
|
2034
2173
|
*
|
|
2035
|
-
*
|
|
2036
|
-
* rotate by angle without driving the pointer FSM, and so re-uses this math.
|
|
2174
|
+
* Module-internal: the box-center rotate transition below is the only caller.
|
|
2037
2175
|
*/
|
|
2038
2176
|
function rotatePointsAround(points, pivot, pivotPixel, angle, projection) {
|
|
2039
2177
|
const cos = Math.cos(angle);
|
|
@@ -2191,49 +2329,6 @@ function beginBoxDrag(args) {
|
|
|
2191
2329
|
};
|
|
2192
2330
|
}
|
|
2193
2331
|
//#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
2332
|
//#region src/tactical-draw/session-history.ts
|
|
2238
2333
|
/** @internal Controller-owned history stack. */
|
|
2239
2334
|
function createSessionHistory(options) {
|
|
@@ -2385,9 +2480,8 @@ function boxToHandles(box, fromPixel) {
|
|
|
2385
2480
|
/**
|
|
2386
2481
|
* The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash so
|
|
2387
2482
|
* 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).
|
|
2483
|
+
* explicitly set their own `strokeDash`, which wins. Used by
|
|
2484
|
+
* `transform-controller.ts` for every group box (member outlines stay solid).
|
|
2391
2485
|
*/
|
|
2392
2486
|
function groupOutlineStyle(base) {
|
|
2393
2487
|
if (base?.strokeDash !== void 0) return base;
|
|
@@ -2400,10 +2494,9 @@ function groupOutlineStyle(base) {
|
|
|
2400
2494
|
* Build the per-member outline features for a group transform — one closed
|
|
2401
2495
|
* `LineString` per `[memberId, box]` pair, id'd
|
|
2402
2496
|
* `${handleIdPrefix}:member-outline:{memberId}` and styled with the plain
|
|
2403
|
-
* (solid) `boxOutline`.
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2406
|
-
* gets the dashed default (`groupOutlineStyle`).
|
|
2497
|
+
* (solid) `boxOutline`. Used by `transform-controller.ts` for every member of a
|
|
2498
|
+
* (possibly mixed) group transform — only the group box gets the dashed default
|
|
2499
|
+
* (`groupOutlineStyle`).
|
|
2407
2500
|
*/
|
|
2408
2501
|
function memberOutlineFeatures(members, fromPixel, handleIdPrefix, style) {
|
|
2409
2502
|
const features = [];
|
|
@@ -2429,51 +2522,12 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
|
2429
2522
|
properties: style ? { style: { ...style } } : {}
|
|
2430
2523
|
};
|
|
2431
2524
|
}
|
|
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
2525
|
/** Estimated glyph width as a fraction of text height — a monospace-ish approximation, not a real text-metrics measurement. */
|
|
2474
2526
|
const CHAR_WIDTH_FACTOR = .6;
|
|
2475
2527
|
/** Estimated line height as a multiple of text height. */
|
|
2476
2528
|
const LINE_HEIGHT_FACTOR = 1.2;
|
|
2529
|
+
/** Screen-space clearance between a label's anchor and its visible drag grip. */
|
|
2530
|
+
const LABEL_HANDLE_OFFSET_PX = 14;
|
|
2477
2531
|
/**
|
|
2478
2532
|
* Normalize `angle` (radians) into `(-π, π]`, matching the range
|
|
2479
2533
|
* `Math.atan2`/screen-angle math elsewhere in `tactical-draw` produces.
|
|
@@ -2559,36 +2613,45 @@ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
|
|
|
2559
2613
|
bearing: view.bearing
|
|
2560
2614
|
});
|
|
2561
2615
|
}
|
|
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
2616
|
/**
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
2582
|
-
*
|
|
2583
|
-
*
|
|
2617
|
+
* Carry user-adjusted label anchors (and rotation overrides) through a
|
|
2618
|
+
* transform-box gesture. Anchors are transformed in pixel space alongside the
|
|
2619
|
+
* control points so the result remains visually rigid across map
|
|
2620
|
+
* projections.
|
|
2621
|
+
*
|
|
2622
|
+
* Rotation: `TransformBox.angle` is radians in screen pixel space, positive =
|
|
2623
|
+
* clockwise on screen (see `transform-box.ts`). Adapters render a label's
|
|
2624
|
+
* on-screen clockwise angle as `π − rotation`, and a rotation delta is
|
|
2625
|
+
* invariant under the map→screen bearing, so an on-screen delta
|
|
2626
|
+
* `Δ = to.angle − from.angle` means `π − rotation' = (π − rotation) + Δ`,
|
|
2627
|
+
* i.e. `rotation' = rotation − Δ`.
|
|
2584
2628
|
*/
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
])
|
|
2629
|
+
function transformAmplifierPlacements(placements, from, to, projection) {
|
|
2630
|
+
if (!placements) return void 0;
|
|
2631
|
+
const scaleX = to.halfWidth / from.halfWidth;
|
|
2632
|
+
const scaleY = to.halfHeight / from.halfHeight;
|
|
2633
|
+
const angleDelta = to.angle - from.angle;
|
|
2634
|
+
const transformed = {};
|
|
2635
|
+
for (const [field, raw] of Object.entries(placements)) {
|
|
2636
|
+
const resolved = resolveAmplifierPlacement(raw);
|
|
2637
|
+
const rotation = resolved.rotation !== void 0 ? resolved.rotation - angleDelta : void 0;
|
|
2638
|
+
if (!resolved.position) {
|
|
2639
|
+
if (rotation !== void 0) transformed[field] = { rotation };
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2642
|
+
const pixel = projection.toPixel(resolved.position);
|
|
2643
|
+
const position = pixel ? (() => {
|
|
2644
|
+
const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
|
|
2645
|
+
const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
|
|
2646
|
+
return projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
|
|
2647
|
+
})() : [...resolved.position];
|
|
2648
|
+
transformed[field] = rotation !== void 0 ? {
|
|
2649
|
+
position,
|
|
2650
|
+
rotation
|
|
2651
|
+
} : position;
|
|
2652
|
+
}
|
|
2653
|
+
return transformed;
|
|
2654
|
+
}
|
|
2592
2655
|
/**
|
|
2593
2656
|
* Field-wise merge of one `amplifierPlacements` entry: fields present in
|
|
2594
2657
|
* `incoming` (valid position / finite rotation) replace the same field on
|
|
@@ -2609,76 +2672,346 @@ function mergeAmplifierPlacementField(existing, incoming) {
|
|
|
2609
2672
|
} : { rotation: merged.rotation };
|
|
2610
2673
|
}
|
|
2611
2674
|
/**
|
|
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
|
|
2675
|
+
* The stateful label-interaction object owned by the edit controller's
|
|
2676
|
+
* `labeldrag` mode (ADR-0034). Holds the selection, the fitted selection box,
|
|
2677
|
+
* and the two live drag states; returns intents for the controller to apply to
|
|
2678
|
+
* its session-owned `workingAmplifierPlacements`.
|
|
2621
2679
|
*/
|
|
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;
|
|
2646
|
-
/**
|
|
2647
|
-
* Set when an Alt+click delete removes the vertex under the pointer, so the
|
|
2648
|
-
* `click` that follows the same pointerup does not read the now-absent handle
|
|
2649
|
-
* as a click-away and close the edit. Consumed by the next `handleClick`.
|
|
2650
|
-
*/
|
|
2651
|
-
let suppressClickAway = false;
|
|
2680
|
+
function createLabelPlacement(deps) {
|
|
2681
|
+
const { projection, view, features, snapshotAmplifierPlacements, handleIdPrefix, interactionStyle } = deps;
|
|
2652
2682
|
/**
|
|
2653
|
-
*
|
|
2654
|
-
*
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
* measures sitting inside a large box stay selectable. Consumed by the next
|
|
2658
|
-
* `handleClick`.
|
|
2683
|
+
* The currently selected label's `labelPlacementKey`, or `null` when no label
|
|
2684
|
+
* is selected. A click on a label toggles this (select / deselect / switch —
|
|
2685
|
+
* see `onUp`'s move-drag click path); it is cleared whenever the feature
|
|
2686
|
+
* disappears from the preview or the mode set drops `labeldrag`.
|
|
2659
2687
|
*/
|
|
2660
|
-
let
|
|
2688
|
+
let selectedLabelKey = null;
|
|
2661
2689
|
/**
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
2665
|
-
*
|
|
2690
|
+
* The transform box fitted around the selected label, re-fitted on every
|
|
2691
|
+
* `handleFeatures` call from the preview features — except while a label
|
|
2692
|
+
* rotate drag is in flight, when it is painted rigidly from the drag's own
|
|
2693
|
+
* live snapshot instead (mirrors `activeBoxDrag.lastSnapshot`). `null` when
|
|
2694
|
+
* nothing is selected or the selection couldn't be fitted this frame.
|
|
2666
2695
|
*/
|
|
2667
|
-
let
|
|
2696
|
+
let labelBox = null;
|
|
2668
2697
|
/**
|
|
2669
|
-
*
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2698
|
+
* A live label *move* drag: direct label grab, the floating grip, or a body
|
|
2699
|
+
* grab inside the selected label's transform box (ADR-0017 box machinery
|
|
2700
|
+
* applied to labels) — all three funnel through this one state, distinguished
|
|
2701
|
+
* only by `grabOffset`. `grabOffset` is the anchor-pixel-minus-down-pixel
|
|
2702
|
+
* delta captured at pointer-down, so `labelAnchorFromPointer` can re-derive
|
|
2703
|
+
* the intended anchor from any subsequent pointer pixel without the label
|
|
2704
|
+
* snapping to the pointer's exact position (a direct grab derives `[0, 0]`;
|
|
2705
|
+
* the floating grip derives `[0, LABEL_HANDLE_OFFSET_PX]`; a box-body grab
|
|
2706
|
+
* derives whatever offset the click landed at within the box).
|
|
2672
2707
|
*/
|
|
2673
|
-
let
|
|
2674
|
-
const changeListeners = /* @__PURE__ */ new Set();
|
|
2675
|
-
const commitEmitter = createCommitEmitter("EditSession.onCommit listener errors");
|
|
2676
|
-
const settleEmitter = createSettleEmitter("EditSession.onSettled listener errors");
|
|
2708
|
+
let activeAmplifierDrag = null;
|
|
2677
2709
|
/**
|
|
2678
|
-
*
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
2710
|
+
* A live label *rotate* drag (ADR-0017 box machinery applied to a label's
|
|
2711
|
+
* rotate grip). `anchorPixel`/`text`/`textAnchor`/`textSizePixels`/
|
|
2712
|
+
* `textSizeMeters` are captured at pointer-down so `lastBox` can be
|
|
2713
|
+
* re-fitted purely from drag math on every move tick — independent of
|
|
2714
|
+
* the preview features, which mirrors `activeBoxDrag.lastSnapshot` and
|
|
2715
|
+
* keeps the box painted even if a transient preview render fails.
|
|
2716
|
+
*/
|
|
2717
|
+
let activeLabelRotateDrag = null;
|
|
2718
|
+
function pickLabel(pixel, tolerance) {
|
|
2719
|
+
for (const feature of features()) {
|
|
2720
|
+
const key = feature.properties?.labelPlacementKey;
|
|
2721
|
+
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2722
|
+
const projected = projection.toPixel(feature.geometry.coordinates);
|
|
2723
|
+
if (!projected) continue;
|
|
2724
|
+
for (const handle of [false, true]) {
|
|
2725
|
+
const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
|
|
2726
|
+
if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
|
|
2727
|
+
key,
|
|
2728
|
+
anchorPixel: projected
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
const box = fitLabelBoxFromFeature(feature, projected, view());
|
|
2732
|
+
if (box && pointInBox(box, pixel)) return {
|
|
2733
|
+
key,
|
|
2734
|
+
anchorPixel: projected
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* The label anchor a pointer pixel maps to: `pixel` shifted by the drag's
|
|
2741
|
+
* `grabOffset` (anchor-pixel-minus-down-pixel, captured at pointer-down —
|
|
2742
|
+
* see `activeAmplifierDrag`), so the label keeps whatever offset it was
|
|
2743
|
+
* grabbed at instead of snapping to the pointer.
|
|
2744
|
+
*/
|
|
2745
|
+
function labelAnchorFromPointer(pixel, grabOffset) {
|
|
2746
|
+
return projection.fromPixel([pixel[0] + grabOffset[0], pixel[1] + grabOffset[1]]);
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* The selected label's rendered feature + its current anchor pixel, or
|
|
2750
|
+
* `null` when nothing is selected, the feature is gone from this frame's
|
|
2751
|
+
* preview, or its anchor fails to project. The single lookup shared by the
|
|
2752
|
+
* box fit (`handleFeatures`) and the box-pick paths (`onDown`).
|
|
2753
|
+
*/
|
|
2754
|
+
function selectedLabelAnchor() {
|
|
2755
|
+
if (!selectedLabelKey) return null;
|
|
2756
|
+
const feature = features().find((f) => f.properties?.labelPlacementKey === selectedLabelKey && f.geometry?.type === "Point");
|
|
2757
|
+
if (!feature || feature.geometry?.type !== "Point") return null;
|
|
2758
|
+
const pixel = projection.toPixel(feature.geometry.coordinates);
|
|
2759
|
+
if (!pixel) return null;
|
|
2760
|
+
return {
|
|
2761
|
+
feature,
|
|
2762
|
+
pixel
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
/** The screen-space angle (radians, y-down) from `anchor` to `pixel`, as used by the label rotate drag's pointer-angle math. */
|
|
2766
|
+
function pointerAngleAround(pixel, anchor) {
|
|
2767
|
+
return Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
2768
|
+
}
|
|
2769
|
+
function handleFeatures() {
|
|
2770
|
+
const style = interactionStyle?.labelHandle;
|
|
2771
|
+
const features_ = [];
|
|
2772
|
+
let index = 0;
|
|
2773
|
+
labelBox = null;
|
|
2774
|
+
if (activeLabelRotateDrag) labelBox = activeLabelRotateDrag.lastBox;
|
|
2775
|
+
else {
|
|
2776
|
+
const anchor = selectedLabelAnchor();
|
|
2777
|
+
if (selectedLabelKey && !anchor) selectedLabelKey = null;
|
|
2778
|
+
else if (anchor) labelBox = fitLabelBoxFromFeature(anchor.feature, anchor.pixel, view());
|
|
2779
|
+
}
|
|
2780
|
+
for (const feature of features()) {
|
|
2781
|
+
const key = feature.properties?.labelPlacementKey;
|
|
2782
|
+
if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
|
|
2783
|
+
if (key === selectedLabelKey) continue;
|
|
2784
|
+
const pixel = projection.toPixel(feature.geometry.coordinates);
|
|
2785
|
+
if (!pixel) continue;
|
|
2786
|
+
features_.push({
|
|
2787
|
+
type: "Feature",
|
|
2788
|
+
id: `${handleIdPrefix}:label-handle:${index}`,
|
|
2789
|
+
geometry: {
|
|
2790
|
+
type: "Point",
|
|
2791
|
+
coordinates: projection.fromPixel([pixel[0], pixel[1] - LABEL_HANDLE_OFFSET_PX])
|
|
2792
|
+
},
|
|
2793
|
+
properties: {
|
|
2794
|
+
handle: "label",
|
|
2795
|
+
index,
|
|
2796
|
+
labelPlacementKey: key,
|
|
2797
|
+
...style ? { style: { ...style } } : {}
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
index += 1;
|
|
2801
|
+
}
|
|
2802
|
+
if (labelBox) {
|
|
2803
|
+
features_.push(boxOutlineFeature(labelBox, projection.fromPixel, `${handleIdPrefix}:label-box-outline`, interactionStyle?.boxOutline));
|
|
2804
|
+
const rotateHandle = {
|
|
2805
|
+
kind: "rotate",
|
|
2806
|
+
index: 0,
|
|
2807
|
+
position: projection.fromPixel(rotateGripPixel(labelBox, 14))
|
|
2808
|
+
};
|
|
2809
|
+
features_.push(...toFeatures([rotateHandle], `${handleIdPrefix}:label`, interactionStyle));
|
|
2810
|
+
}
|
|
2811
|
+
return features_;
|
|
2812
|
+
}
|
|
2813
|
+
function onDown(pixel, tolerance) {
|
|
2814
|
+
if (labelBox) {
|
|
2815
|
+
const anchor = selectedLabelAnchor();
|
|
2816
|
+
if (anchor) {
|
|
2817
|
+
const gripPixel = rotateGripPixel(labelBox, 14);
|
|
2818
|
+
if (Math.hypot(gripPixel[0] - pixel[0], gripPixel[1] - pixel[1]) <= tolerance) {
|
|
2819
|
+
const props = anchor.feature.properties ?? {};
|
|
2820
|
+
activeLabelRotateDrag = {
|
|
2821
|
+
key: selectedLabelKey,
|
|
2822
|
+
anchorPixel: anchor.pixel,
|
|
2823
|
+
startPointerAngle: pointerAngleAround(pixel, anchor.pixel),
|
|
2824
|
+
startRotation: typeof props.rotation === "number" ? props.rotation : 0,
|
|
2825
|
+
previous: snapshotAmplifierPlacements(),
|
|
2826
|
+
downPixel: pixel,
|
|
2827
|
+
text: typeof props.text === "string" ? props.text : void 0,
|
|
2828
|
+
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
2829
|
+
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
2830
|
+
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
2831
|
+
lastBox: labelBox
|
|
2832
|
+
};
|
|
2833
|
+
return true;
|
|
2834
|
+
}
|
|
2835
|
+
if (pointInBox(labelBox, pixel)) {
|
|
2836
|
+
activeAmplifierDrag = {
|
|
2837
|
+
field: selectedLabelKey,
|
|
2838
|
+
grabOffset: [anchor.pixel[0] - pixel[0], anchor.pixel[1] - pixel[1]],
|
|
2839
|
+
previous: snapshotAmplifierPlacements(),
|
|
2840
|
+
downPixel: pixel
|
|
2841
|
+
};
|
|
2842
|
+
return true;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
const labelHit = pickLabel(pixel, tolerance);
|
|
2847
|
+
if (labelHit) {
|
|
2848
|
+
activeAmplifierDrag = {
|
|
2849
|
+
field: labelHit.key,
|
|
2850
|
+
grabOffset: [labelHit.anchorPixel[0] - pixel[0], labelHit.anchorPixel[1] - pixel[1]],
|
|
2851
|
+
previous: snapshotAmplifierPlacements(),
|
|
2852
|
+
downPixel: pixel
|
|
2853
|
+
};
|
|
2854
|
+
return true;
|
|
2855
|
+
}
|
|
2856
|
+
return false;
|
|
2857
|
+
}
|
|
2858
|
+
function onMove(pixel) {
|
|
2859
|
+
if (activeLabelRotateDrag) {
|
|
2860
|
+
const drag = activeLabelRotateDrag;
|
|
2861
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
2862
|
+
const rotation = drag.startRotation - delta;
|
|
2863
|
+
const { resolution, bearing } = view();
|
|
2864
|
+
const liveBox = fitLabelTransformBox({
|
|
2865
|
+
text: drag.text,
|
|
2866
|
+
rotation,
|
|
2867
|
+
textAnchor: drag.textAnchor,
|
|
2868
|
+
textSizePixels: drag.textSizePixels,
|
|
2869
|
+
textSizeMeters: drag.textSizeMeters,
|
|
2870
|
+
anchor: drag.anchorPixel,
|
|
2871
|
+
resolution,
|
|
2872
|
+
bearing
|
|
2873
|
+
});
|
|
2874
|
+
if (liveBox) drag.lastBox = liveBox;
|
|
2875
|
+
return {
|
|
2876
|
+
field: drag.key,
|
|
2877
|
+
value: { rotation }
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
if (activeAmplifierDrag) {
|
|
2881
|
+
const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.grabOffset);
|
|
2882
|
+
if (!position) return null;
|
|
2883
|
+
return {
|
|
2884
|
+
field: activeAmplifierDrag.field,
|
|
2885
|
+
value: position
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
return null;
|
|
2889
|
+
}
|
|
2890
|
+
function onUp(pixel) {
|
|
2891
|
+
if (activeLabelRotateDrag) {
|
|
2892
|
+
const drag = activeLabelRotateDrag;
|
|
2893
|
+
activeLabelRotateDrag = null;
|
|
2894
|
+
if (!withinClickSlop(pixel, drag.downPixel)) {
|
|
2895
|
+
const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
|
|
2896
|
+
const rotation = drag.startRotation - delta;
|
|
2897
|
+
return {
|
|
2898
|
+
kind: "rotate",
|
|
2899
|
+
key: drag.key,
|
|
2900
|
+
rotation,
|
|
2901
|
+
previous: drag.previous
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
return {
|
|
2905
|
+
kind: "click",
|
|
2906
|
+
previous: drag.previous
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
if (activeAmplifierDrag) {
|
|
2910
|
+
const { field, grabOffset, previous, downPixel } = activeAmplifierDrag;
|
|
2911
|
+
activeAmplifierDrag = null;
|
|
2912
|
+
const position = labelAnchorFromPointer(pixel, grabOffset);
|
|
2913
|
+
if (position && !withinClickSlop(pixel, downPixel)) return {
|
|
2914
|
+
kind: "move",
|
|
2915
|
+
field,
|
|
2916
|
+
position,
|
|
2917
|
+
previous
|
|
2918
|
+
};
|
|
2919
|
+
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
2920
|
+
return {
|
|
2921
|
+
kind: "click",
|
|
2922
|
+
previous
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
return null;
|
|
2926
|
+
}
|
|
2927
|
+
return {
|
|
2928
|
+
handleFeatures,
|
|
2929
|
+
onDown,
|
|
2930
|
+
onMove,
|
|
2931
|
+
onUp,
|
|
2932
|
+
get dragging() {
|
|
2933
|
+
return activeAmplifierDrag !== null || activeLabelRotateDrag !== null;
|
|
2934
|
+
},
|
|
2935
|
+
endDrag() {
|
|
2936
|
+
activeAmplifierDrag = null;
|
|
2937
|
+
activeLabelRotateDrag = null;
|
|
2938
|
+
},
|
|
2939
|
+
syncModes(modes) {
|
|
2940
|
+
if (!modes.includes("labeldrag")) selectedLabelKey = null;
|
|
2941
|
+
}
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
//#endregion
|
|
2945
|
+
//#region src/tactical-draw/edit-controller.ts
|
|
2946
|
+
const ALLOWED_EDIT_MODES = new Set([
|
|
2947
|
+
"reshape",
|
|
2948
|
+
"transform",
|
|
2949
|
+
"labeldrag",
|
|
2950
|
+
"delete"
|
|
2951
|
+
]);
|
|
2952
|
+
/**
|
|
2953
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
2954
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
2955
|
+
*
|
|
2956
|
+
* - lift-original (remove from graphics layer) + working copy
|
|
2957
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
2958
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
2959
|
+
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
2960
|
+
* - default click-away close policy
|
|
2961
|
+
* - close / abort / signal / preempt / destroy paths
|
|
2962
|
+
*/
|
|
2963
|
+
function runEdit(opts) {
|
|
2964
|
+
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
|
|
2965
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2966
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2967
|
+
const metadata = getControlMeasureMetadata(measure.kind);
|
|
2968
|
+
const rule = metadata.rule;
|
|
2969
|
+
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
2970
|
+
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
2971
|
+
const closedRing = rule?.closedRing ?? false;
|
|
2972
|
+
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
2973
|
+
const isAreaKind = metadata.geometry === "area";
|
|
2974
|
+
const requestedModes = normaliseModes(initialModes);
|
|
2975
|
+
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2976
|
+
const workingPoints = editStartPoints.map((p) => [...p]);
|
|
2977
|
+
let workingOptions = { ...measure.options ?? {} };
|
|
2978
|
+
let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
|
|
2979
|
+
let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
|
|
2980
|
+
function snapshotAmplifierPlacements() {
|
|
2981
|
+
return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
|
|
2982
|
+
}
|
|
2983
|
+
let modes = requestedModes;
|
|
2984
|
+
let dirty = false;
|
|
2985
|
+
let nonGeometryDirty = false;
|
|
2986
|
+
let dragInFlight = false;
|
|
2987
|
+
/**
|
|
2988
|
+
* Mutable style snapshot. Initialised from the edit-start input; replaced
|
|
2989
|
+
* when the façade routes an external style change to us via
|
|
2990
|
+
* `notifyExternalMeasure`. `EditSession.measure` stays the immutable
|
|
2991
|
+
* edit-start input per PRD #40 user-story 30.
|
|
2992
|
+
*/
|
|
2993
|
+
let currentStyle = measure.style;
|
|
2994
|
+
/**
|
|
2995
|
+
* `setModes()` called during an in-flight drag is queued here and drained
|
|
2996
|
+
* when the gesture ends — handles must not disappear under the pointer
|
|
2997
|
+
* (PRD #40 user-story-21).
|
|
2998
|
+
*/
|
|
2999
|
+
let pendingModes = null;
|
|
3000
|
+
const changeEmitter = createChangeEmitter({
|
|
3001
|
+
listenerErrorsMessage: "EditSession.onChange listener errors",
|
|
3002
|
+
isSettled: () => ctx.settled,
|
|
3003
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
3004
|
+
close: () => {
|
|
3005
|
+
ctx.commit(rebuildRendered);
|
|
3006
|
+
}
|
|
3007
|
+
});
|
|
3008
|
+
const commitEmitter = createCommitEmitter("EditSession.onCommit listener errors");
|
|
3009
|
+
const settleEmitter = createSettleEmitter("EditSession.onSettled listener errors");
|
|
3010
|
+
/**
|
|
3011
|
+
* The transform box's rotation, in radians, session-scoped (ADR-0017 decision
|
|
3012
|
+
* #2): starts at `0` (axis-aligned) and accumulates every committed rotate
|
|
3013
|
+
* gesture's delta, so the box stays glued to the shape across gestures. Never
|
|
3014
|
+
* persisted on the measure; a fresh edit always starts back at `0`.
|
|
2682
3015
|
*/
|
|
2683
3016
|
let boxAngle = 0;
|
|
2684
3017
|
function captureHistory() {
|
|
@@ -2713,28 +3046,24 @@ function runEdit(opts) {
|
|
|
2713
3046
|
let box = null;
|
|
2714
3047
|
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
2715
3048
|
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
3049
|
let ctx;
|
|
2733
3050
|
let driver;
|
|
3051
|
+
let shell;
|
|
2734
3052
|
const dragProjection = {
|
|
2735
3053
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2736
3054
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2737
3055
|
};
|
|
3056
|
+
const labels = createLabelPlacement({
|
|
3057
|
+
projection: dragProjection,
|
|
3058
|
+
view: () => ({
|
|
3059
|
+
resolution: adapter.getResolution(),
|
|
3060
|
+
bearing: adapter.getBearing()
|
|
3061
|
+
}),
|
|
3062
|
+
features: () => lastPreviewFeatures,
|
|
3063
|
+
snapshotAmplifierPlacements,
|
|
3064
|
+
handleIdPrefix,
|
|
3065
|
+
interactionStyle
|
|
3066
|
+
});
|
|
2738
3067
|
/**
|
|
2739
3068
|
* Fit the transform box around `features`' rendered geometry: every
|
|
2740
3069
|
* coordinate is projected to pixels via the pointer driver and
|
|
@@ -2807,51 +3136,7 @@ function runEdit(opts) {
|
|
|
2807
3136
|
trailingFixedSlots,
|
|
2808
3137
|
closedRing
|
|
2809
3138
|
}), 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
|
-
}
|
|
3139
|
+
if (modes.includes("labeldrag")) features.push(...labels.handleFeatures());
|
|
2855
3140
|
if (modes.includes("transform")) {
|
|
2856
3141
|
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
2857
3142
|
if (liveBox) {
|
|
@@ -2888,7 +3173,7 @@ function runEdit(opts) {
|
|
|
2888
3173
|
return rebuildRenderedWith(workingOptions);
|
|
2889
3174
|
}
|
|
2890
3175
|
function recomputeDirty() {
|
|
2891
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints,
|
|
3176
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, 1e-9);
|
|
2892
3177
|
}
|
|
2893
3178
|
function replaceWorkingPoints(next) {
|
|
2894
3179
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -2914,65 +3199,37 @@ function runEdit(opts) {
|
|
|
2914
3199
|
};
|
|
2915
3200
|
}
|
|
2916
3201
|
/**
|
|
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.
|
|
3202
|
+
* Emit a completed gesture through the shared change-emit protocol
|
|
3203
|
+
* (`createChangeEmitter`, issue #49). The pre-gesture snapshot is provided as
|
|
3204
|
+
* `previousRendered`; `commitGesture` has already mutated `workingPoints` and
|
|
3205
|
+
* re-rendered preview / handle layers before this is called. Only this
|
|
3206
|
+
* controller's variance stays here: the event payload, the reject rollback
|
|
3207
|
+
* (restore the pre-gesture working state), and the recordable-source guard on
|
|
3208
|
+
* the history record. The deferred reject/close/abort machinery, exception
|
|
3209
|
+
* collection, and record-before-rethrow ordering live in the emitter.
|
|
2931
3210
|
*/
|
|
2932
3211
|
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 = {
|
|
3212
|
+
changeEmitter.emit({
|
|
2938
3213
|
source,
|
|
2939
3214
|
graphic: currentRendered,
|
|
2940
3215
|
previous: previousRendered,
|
|
2941
|
-
session
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
3216
|
+
session
|
|
3217
|
+
}, {
|
|
3218
|
+
rollback() {
|
|
3219
|
+
replaceWorkingPoints(previousPoints);
|
|
3220
|
+
replaceWorkingOptions(previousOptions);
|
|
3221
|
+
currentStyle = previousStyle;
|
|
3222
|
+
sizeAnchor = previousSizeAnchor;
|
|
3223
|
+
workingTextAmplifiers = previousTextAmplifiers;
|
|
3224
|
+
workingAmplifierPlacements = previousAmplifierPlacements;
|
|
3225
|
+
boxAngle = previousBoxAngle;
|
|
3226
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
3227
|
+
renderPreview();
|
|
3228
|
+
renderHandlesLayer();
|
|
3229
|
+
recomputeDirty();
|
|
2947
3230
|
},
|
|
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");
|
|
3231
|
+
onAccepted: source === "gesture" || source === "programmatic" ? () => history.record(captureHistory(), historyLabel) : void 0
|
|
3232
|
+
});
|
|
2976
3233
|
}
|
|
2977
3234
|
/**
|
|
2978
3235
|
* Apply a completed gesture: snapshot pre-state, write the new control
|
|
@@ -3161,35 +3418,6 @@ function runEdit(opts) {
|
|
|
3161
3418
|
dirty = true;
|
|
3162
3419
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change size anchor");
|
|
3163
3420
|
}
|
|
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
3421
|
function modesEqual(a, b) {
|
|
3194
3422
|
if (a.length !== b.length) return false;
|
|
3195
3423
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
@@ -3198,7 +3426,7 @@ function runEdit(opts) {
|
|
|
3198
3426
|
function applyModes(next) {
|
|
3199
3427
|
if (modesEqual(modes, next)) return;
|
|
3200
3428
|
modes = next;
|
|
3201
|
-
|
|
3429
|
+
labels.syncModes(modes);
|
|
3202
3430
|
renderHandlesLayer();
|
|
3203
3431
|
}
|
|
3204
3432
|
function drainPendingModes() {
|
|
@@ -3322,10 +3550,7 @@ function runEdit(opts) {
|
|
|
3322
3550
|
},
|
|
3323
3551
|
onChange(listener) {
|
|
3324
3552
|
if (ctx.settled) return () => {};
|
|
3325
|
-
|
|
3326
|
-
return () => {
|
|
3327
|
-
changeListeners.delete(listener);
|
|
3328
|
-
};
|
|
3553
|
+
return changeEmitter.register(listener);
|
|
3329
3554
|
},
|
|
3330
3555
|
onCommit(listener) {
|
|
3331
3556
|
if (ctx.settled) return () => {};
|
|
@@ -3333,24 +3558,6 @@ function runEdit(opts) {
|
|
|
3333
3558
|
},
|
|
3334
3559
|
onSettled(handler) {
|
|
3335
3560
|
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
3561
|
}
|
|
3355
3562
|
};
|
|
3356
3563
|
let activeDrag = null;
|
|
@@ -3387,83 +3594,6 @@ function runEdit(opts) {
|
|
|
3387
3594
|
return [pixel[0] + result.pixel[0] - anchorPixel[0], pixel[1] + result.pixel[1] - anchorPixel[1]];
|
|
3388
3595
|
}
|
|
3389
3596
|
/**
|
|
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
3597
|
* A live transform-box gesture (corner scale, box rotate, or body
|
|
3468
3598
|
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
3469
3599
|
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
@@ -3510,51 +3640,8 @@ function runEdit(opts) {
|
|
|
3510
3640
|
}
|
|
3511
3641
|
const onDriverDown = (pixel, info) => {
|
|
3512
3642
|
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
|
-
};
|
|
3643
|
+
const tol = info?.hitTolerance ?? 12;
|
|
3644
|
+
if (modes.includes("labeldrag") && labels.onDown(pixel, tol)) {
|
|
3558
3645
|
dragInFlight = true;
|
|
3559
3646
|
driver.setDragPanEnabled(false);
|
|
3560
3647
|
return;
|
|
@@ -3568,13 +3655,13 @@ function runEdit(opts) {
|
|
|
3568
3655
|
if (modes.includes("delete")) {
|
|
3569
3656
|
if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
|
|
3570
3657
|
deleteVertex(hit.index);
|
|
3571
|
-
|
|
3658
|
+
shell.suppressNextClickAway();
|
|
3572
3659
|
}
|
|
3573
3660
|
return;
|
|
3574
3661
|
}
|
|
3575
3662
|
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
3576
3663
|
deleteVertex(hit.index);
|
|
3577
|
-
|
|
3664
|
+
shell.suppressNextClickAway();
|
|
3578
3665
|
return;
|
|
3579
3666
|
}
|
|
3580
3667
|
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
@@ -3655,39 +3742,17 @@ function runEdit(opts) {
|
|
|
3655
3742
|
}
|
|
3656
3743
|
const onDriverMove = (pixel, info) => {
|
|
3657
3744
|
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();
|
|
3745
|
+
if (labels.dragging) {
|
|
3746
|
+
const patch = labels.onMove(pixel);
|
|
3747
|
+
if (patch) {
|
|
3748
|
+
const merged = mergeAmplifierPlacementField(workingAmplifierPlacements?.[patch.field], patch.value);
|
|
3749
|
+
workingAmplifierPlacements = {
|
|
3750
|
+
...workingAmplifierPlacements ?? {},
|
|
3751
|
+
...merged !== void 0 ? { [patch.field]: merged } : {}
|
|
3752
|
+
};
|
|
3753
|
+
renderPreview();
|
|
3754
|
+
renderHandlesLayer();
|
|
3755
|
+
}
|
|
3691
3756
|
} else if (activeDrag) paintDragSnapshot(pixel, info);
|
|
3692
3757
|
else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info, info?.shiftKey);
|
|
3693
3758
|
};
|
|
@@ -3698,35 +3763,17 @@ function runEdit(opts) {
|
|
|
3698
3763
|
drainPendingModes();
|
|
3699
3764
|
}
|
|
3700
3765
|
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;
|
|
3766
|
+
const labelOutcome = labels.onUp(pixel);
|
|
3767
|
+
if (labelOutcome) {
|
|
3768
|
+
workingAmplifierPlacements = labelOutcome.previous;
|
|
3722
3769
|
endDragBookkeeping();
|
|
3723
|
-
if (
|
|
3770
|
+
if (labelOutcome.kind === "rotate") setAmplifierPlacements({ [labelOutcome.key]: { rotation: labelOutcome.rotation } }, "gesture", "Rotate label");
|
|
3771
|
+
else if (labelOutcome.kind === "move") setAmplifierPlacements({ [labelOutcome.field]: labelOutcome.position }, "gesture", "Move label");
|
|
3724
3772
|
else {
|
|
3725
|
-
selectedLabelKey = selectedLabelKey === field ? null : field;
|
|
3726
3773
|
renderPreview();
|
|
3727
3774
|
renderHandlesLayer();
|
|
3728
3775
|
}
|
|
3729
|
-
|
|
3776
|
+
shell.suppressNextClickAway();
|
|
3730
3777
|
return;
|
|
3731
3778
|
}
|
|
3732
3779
|
if (activeDrag) {
|
|
@@ -3754,15 +3801,14 @@ function runEdit(opts) {
|
|
|
3754
3801
|
const { fsm, preDragPoints, preDragAmplifierPlacements, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
|
|
3755
3802
|
activeBoxDrag = null;
|
|
3756
3803
|
driver.setDragPanEnabled(true);
|
|
3757
|
-
|
|
3758
|
-
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$3) {
|
|
3804
|
+
if (fsm.kind === "body" && withinClickSlop(pixel, downPixel)) {
|
|
3759
3805
|
replaceWorkingPoints(preDragPoints);
|
|
3760
3806
|
workingAmplifierPlacements = preDragAmplifierPlacements;
|
|
3761
3807
|
renderPreview();
|
|
3762
3808
|
renderHandlesLayer();
|
|
3763
3809
|
recomputeDirty();
|
|
3764
3810
|
endDragBookkeeping();
|
|
3765
|
-
|
|
3811
|
+
shell.noteBodyClick();
|
|
3766
3812
|
return;
|
|
3767
3813
|
}
|
|
3768
3814
|
const finalPixel = translatedBoxPixel(pixel, info, {
|
|
@@ -3778,44 +3824,7 @@ function runEdit(opts) {
|
|
|
3778
3824
|
return;
|
|
3779
3825
|
}
|
|
3780
3826
|
};
|
|
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({
|
|
3827
|
+
const core = runEditInteraction({
|
|
3819
3828
|
adapter,
|
|
3820
3829
|
signal,
|
|
3821
3830
|
layers: [
|
|
@@ -3823,44 +3832,37 @@ function runEdit(opts) {
|
|
|
3823
3832
|
guideLayer,
|
|
3824
3833
|
handlesLayer
|
|
3825
3834
|
],
|
|
3835
|
+
graphicsLayer,
|
|
3836
|
+
previewLayer,
|
|
3837
|
+
handlesLayer,
|
|
3838
|
+
closeOnClickAway,
|
|
3826
3839
|
onViewChange: rescaleOnViewChange,
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3840
|
+
onDown: onDriverDown,
|
|
3841
|
+
onMove: onDriverMove,
|
|
3842
|
+
onUp: onDriverUp,
|
|
3843
|
+
ownsGraphic: (id) => id === measure.id,
|
|
3844
|
+
close: () => {
|
|
3845
|
+
ctx.commit(rebuildRendered);
|
|
3846
|
+
},
|
|
3847
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
3848
|
+
historySettle: () => history.settle(),
|
|
3849
|
+
facadeOnSettled: onSettled,
|
|
3850
|
+
emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
|
|
3851
|
+
extraTeardown: () => {
|
|
3852
|
+
if (activeDrag !== null || activeBoxDrag !== null || labels.dragging) {
|
|
3853
|
+
driver.setDragPanEnabled(true);
|
|
3854
|
+
activeDrag = null;
|
|
3855
|
+
activeBoxDrag = null;
|
|
3856
|
+
labels.endDrag();
|
|
3857
|
+
dragInFlight = false;
|
|
3833
3858
|
}
|
|
3834
3859
|
},
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3860
|
+
onAbort: wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0,
|
|
3861
|
+
attach(interaction, createdDriver, createdShell) {
|
|
3862
|
+
ctx = interaction;
|
|
3863
|
+
driver = createdDriver;
|
|
3864
|
+
shell = createdShell;
|
|
3838
3865
|
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
3866
|
renderPreview();
|
|
3865
3867
|
renderHandlesLayer();
|
|
3866
3868
|
recomputeDirty();
|
|
@@ -3922,125 +3924,282 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
|
3922
3924
|
return true;
|
|
3923
3925
|
}
|
|
3924
3926
|
//#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;
|
|
3927
|
+
//#region src/tactical-draw/errors.ts
|
|
3941
3928
|
/**
|
|
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
|
|
3929
|
+
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
3930
|
+
* See PRD #40 / issue #45.
|
|
3958
3931
|
*/
|
|
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
|
-
};
|
|
3932
|
+
var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
|
|
3933
|
+
constructor(message = "TacticalDraw instance has been destroyed") {
|
|
3934
|
+
super(message);
|
|
3935
|
+
this.name = "TacticalDrawDestroyedError";
|
|
3936
|
+
Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
|
|
3991
3937
|
}
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3938
|
+
};
|
|
3939
|
+
var PointSymbolCapabilityRequiredError = class extends Error {
|
|
3940
|
+
constructor() {
|
|
3941
|
+
super("TacticalDraw: point-symbol capability is not configured");
|
|
3942
|
+
this.name = "PointSymbolCapabilityRequiredError";
|
|
3996
3943
|
}
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
3944
|
+
};
|
|
3945
|
+
//#endregion
|
|
3946
|
+
//#region src/tactical-draw/point-symbol-rendering.ts
|
|
3947
|
+
function effectivePointSymbolPixelSize(symbol, resolution) {
|
|
3948
|
+
if (symbol.size.unit === "pixels") return symbol.size.value;
|
|
3949
|
+
if (resolution === void 0 || resolution <= 0) return symbol.size.value;
|
|
3950
|
+
const px = symbol.size.value / resolution;
|
|
3951
|
+
const { minPixels, maxPixels } = symbol.size;
|
|
3952
|
+
return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
|
|
3953
|
+
}
|
|
3954
|
+
function pointSymbolPixelScale(symbol, render, resolution) {
|
|
3955
|
+
if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
|
|
3956
|
+
return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
|
|
3957
|
+
}
|
|
3958
|
+
/** Complete rotated logical footprint for selection and transform chrome. */
|
|
3959
|
+
function pointSymbolFootprint(symbol, render, adapter) {
|
|
3960
|
+
const anchor = adapter.getPixelFromCoordinate(symbol.position);
|
|
3961
|
+
if (!anchor) return null;
|
|
3962
|
+
const scale = pointSymbolPixelScale(symbol, render, adapter.getResolution());
|
|
3963
|
+
const angle = symbol.rotation - adapter.getBearing();
|
|
3964
|
+
const centerOffsetX = (render.width / 2 - render.anchor.x) * scale;
|
|
3965
|
+
const centerOffsetY = (render.height / 2 - render.anchor.y) * scale;
|
|
3966
|
+
const cos = Math.cos(angle);
|
|
3967
|
+
const sin = Math.sin(angle);
|
|
3968
|
+
const box = {
|
|
3969
|
+
center: [anchor[0] + centerOffsetX * cos - centerOffsetY * sin, anchor[1] + centerOffsetX * sin + centerOffsetY * cos],
|
|
3970
|
+
halfWidth: render.width * scale / 2,
|
|
3971
|
+
halfHeight: render.height * scale / 2,
|
|
3972
|
+
angle
|
|
3973
|
+
};
|
|
3974
|
+
return {
|
|
3975
|
+
box,
|
|
3976
|
+
corners: boxCorners(box)
|
|
3977
|
+
};
|
|
3978
|
+
}
|
|
3979
|
+
function renderPointSymbolForAdapter(adapter, capability, symbol) {
|
|
3980
|
+
const render = capability.render(symbol);
|
|
3981
|
+
return {
|
|
3982
|
+
render,
|
|
3983
|
+
resource: {
|
|
3984
|
+
id: render.resourceId,
|
|
3985
|
+
svg: render.svg,
|
|
3986
|
+
width: render.width,
|
|
3987
|
+
height: render.height,
|
|
3988
|
+
anchor: [render.anchor.x, render.anchor.y]
|
|
3989
|
+
},
|
|
3990
|
+
feature: pointSymbolFeature(symbol, render, adapter.getResolution())
|
|
3991
|
+
};
|
|
3992
|
+
}
|
|
3993
|
+
function pointSymbolFeature(symbol, render, resolution) {
|
|
3994
|
+
const groundSizeProperties = symbol.size.unit === "meters" ? {
|
|
3995
|
+
iconSizeMeters: symbol.size.value,
|
|
3996
|
+
iconOctagonSize: render.octagonSize,
|
|
3997
|
+
...boundProperty("iconMinPixels", symbol.size.minPixels),
|
|
3998
|
+
...boundProperty("iconMaxPixels", symbol.size.maxPixels)
|
|
3999
|
+
} : void 0;
|
|
4000
|
+
return {
|
|
4001
|
+
type: "Feature",
|
|
4002
|
+
id: `${symbol.id}:point-symbol`,
|
|
4003
|
+
geometry: {
|
|
4004
|
+
type: "Point",
|
|
4005
|
+
coordinates: [...symbol.position]
|
|
4006
|
+
},
|
|
4007
|
+
properties: {
|
|
4008
|
+
...symbol.properties ?? {},
|
|
4009
|
+
graphicId: symbol.id,
|
|
4010
|
+
graphicKind: "point-symbol",
|
|
4011
|
+
iconId: render.resourceId,
|
|
4012
|
+
iconScale: pointSymbolPixelScale(symbol, render, resolution),
|
|
4013
|
+
iconRotation: symbol.rotation,
|
|
4014
|
+
iconOpacity: symbol.style?.opacity ?? 1,
|
|
4015
|
+
...groundSizeProperties
|
|
4016
|
+
}
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
function boundProperty(key, value) {
|
|
4020
|
+
return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
|
|
4021
|
+
}
|
|
4022
|
+
//#endregion
|
|
4023
|
+
//#region src/tactical-draw/transform-controller.ts
|
|
4024
|
+
/** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
|
|
4025
|
+
const MIN_POINT_SYMBOL_SIZE = .1;
|
|
4026
|
+
/**
|
|
4027
|
+
* The uniform scale factor and rotation delta a box implies relative to a
|
|
4028
|
+
* pre-drag box, given the gesture kind. Body translate reports neither (1, 0);
|
|
4029
|
+
* measures ignore this and take their geometry straight from the transformed
|
|
4030
|
+
* anchor points — only point symbols consume it.
|
|
4031
|
+
*/
|
|
4032
|
+
function boxScaleRotation(kind, box, preDragBox) {
|
|
4033
|
+
return {
|
|
4034
|
+
scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
|
|
4035
|
+
rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
/** Apply a box's scale/rotation deltas to each member's pre-drag point-symbol state. */
|
|
4039
|
+
function scalePointSymbolStates(pre, scale, rotation) {
|
|
4040
|
+
return pre.map((state) => state ? {
|
|
4041
|
+
size: Math.max(MIN_POINT_SYMBOL_SIZE, state.size * scale),
|
|
4042
|
+
rotation: normalizeRotation(state.rotation + rotation)
|
|
4043
|
+
} : null);
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Drive an interactive group transform against a `MapAdapter` — the leaner,
|
|
4047
|
+
* multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
|
|
4048
|
+
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
4049
|
+
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
4050
|
+
* grip, drag-anywhere body translate), applied rigidly to every member at once.
|
|
4051
|
+
*
|
|
4052
|
+
* Typed on `Graphic` (ADR-0032): control measures and point symbols coexist in
|
|
4053
|
+
* one session. A measure's box gesture rewrites its control points (and bakes
|
|
4054
|
+
* pixel size per the group `sizeAnchor`); a point symbol's rewrites its
|
|
4055
|
+
* position, scales its size value, and rotates its rotation — its persisted
|
|
4056
|
+
* size unit is never converted.
|
|
4057
|
+
*
|
|
4058
|
+
* - lift every member off the graphics layer + per-member working copies
|
|
4059
|
+
* - render every member's preview to the shared preview layer each paint
|
|
4060
|
+
* - group box = fit over the union of every member's rendered pixel footprint
|
|
4061
|
+
* at the session `boxAngle`; each member also gets its own thin outline
|
|
4062
|
+
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
4063
|
+
* contracts as `runEdit`, scoped to the group
|
|
4064
|
+
* - membership is mutable via `session.setGraphics` (#187): members join/leave
|
|
4065
|
+
* a live session without a session boundary
|
|
4066
|
+
*/
|
|
4067
|
+
function runGroupTransform(opts) {
|
|
4068
|
+
const { adapter, capability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
4069
|
+
assertCapabilityFor(graphics);
|
|
4070
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
4071
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
4072
|
+
/** Throw synchronously if any graphic in `list` is a point symbol and no capability was supplied. */
|
|
4073
|
+
function assertCapabilityFor(list) {
|
|
4074
|
+
if (!capability && list.some(isPointSymbol)) throw new PointSymbolCapabilityRequiredError();
|
|
4075
|
+
}
|
|
4076
|
+
function isPointSymbolMember(member) {
|
|
4077
|
+
return isPointSymbol(member.graphic);
|
|
4078
|
+
}
|
|
4079
|
+
/** Build a fresh working record for `graphic`, snapshotting its edit-start state. */
|
|
4080
|
+
function createMemberState(graphic) {
|
|
4081
|
+
if (isPointSymbol(graphic)) {
|
|
4082
|
+
const editStartPoints = [[...graphic.position]];
|
|
4083
|
+
return {
|
|
4084
|
+
graphic,
|
|
4085
|
+
editStartPoints,
|
|
4086
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
4087
|
+
editStartSize: graphic.size.value,
|
|
4088
|
+
editStartRotation: graphic.rotation,
|
|
4089
|
+
workingSize: graphic.size.value,
|
|
4090
|
+
workingRotation: graphic.rotation,
|
|
4091
|
+
workingAmplifierPlacements: void 0,
|
|
4092
|
+
optionsOverride: null,
|
|
4093
|
+
box: null,
|
|
4094
|
+
lastBoxPixels: []
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
const editStartPoints = graphic.controlPoints.map((p) => [...p]);
|
|
4098
|
+
return {
|
|
4099
|
+
graphic,
|
|
4100
|
+
editStartPoints,
|
|
4101
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
4102
|
+
editStartSize: 0,
|
|
4103
|
+
editStartRotation: 0,
|
|
4104
|
+
workingSize: 0,
|
|
4105
|
+
workingRotation: 0,
|
|
4106
|
+
workingAmplifierPlacements: graphic.amplifierPlacements ? structuredClone(graphic.amplifierPlacements) : void 0,
|
|
4107
|
+
optionsOverride: null,
|
|
4108
|
+
box: null,
|
|
4109
|
+
lastBoxPixels: []
|
|
4004
4110
|
};
|
|
4005
|
-
|
|
4111
|
+
}
|
|
4112
|
+
/** The single mutable source of truth for the current member set. */
|
|
4113
|
+
let members = graphics.map(createMemberState);
|
|
4114
|
+
/**
|
|
4115
|
+
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
4116
|
+
* mid-session `setGraphics` add). Gates the abort-path restore so a session
|
|
4117
|
+
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
4118
|
+
*/
|
|
4119
|
+
let anyLiftedFromGraphics = false;
|
|
4120
|
+
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
4121
|
+
function mergeOptions(base, override) {
|
|
4122
|
+
if (!override) return base;
|
|
4006
4123
|
return {
|
|
4007
|
-
|
|
4008
|
-
|
|
4124
|
+
...base ?? {},
|
|
4125
|
+
...override
|
|
4009
4126
|
};
|
|
4010
4127
|
}
|
|
4011
|
-
/**
|
|
4012
|
-
function
|
|
4128
|
+
/** Effective options for measure member `i`: its own options merged with its *live* override. */
|
|
4129
|
+
function effectiveOptions(i) {
|
|
4013
4130
|
const member = members[i];
|
|
4131
|
+
return mergeOptions(member.graphic.options, member.optionsOverride);
|
|
4132
|
+
}
|
|
4133
|
+
/** Build the working point symbol for a member from its live working state. */
|
|
4134
|
+
function buildWorkingPointSymbol(member) {
|
|
4135
|
+
const symbol = clonePointSymbol(member.graphic);
|
|
4136
|
+
symbol.position = [...member.workingPoints[0]];
|
|
4137
|
+
symbol.size = {
|
|
4138
|
+
...symbol.size,
|
|
4139
|
+
value: member.workingSize
|
|
4140
|
+
};
|
|
4141
|
+
symbol.rotation = member.workingRotation;
|
|
4142
|
+
return symbol;
|
|
4143
|
+
}
|
|
4144
|
+
/** Build the working measure for member `i` with a given options override. */
|
|
4145
|
+
function buildWorkingMeasureWith(member, override) {
|
|
4146
|
+
const measure = member.graphic;
|
|
4147
|
+
const options = mergeOptions(measure.options, override);
|
|
4014
4148
|
return {
|
|
4015
|
-
...
|
|
4016
|
-
options: effectiveOptions(i),
|
|
4149
|
+
...measure,
|
|
4017
4150
|
controlPoints: member.workingPoints.map((p) => [...p]),
|
|
4151
|
+
...options !== void 0 ? { options } : {},
|
|
4018
4152
|
...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
|
|
4019
4153
|
};
|
|
4020
4154
|
}
|
|
4155
|
+
/** Build a member's committed snapshot with a given override; point symbols never bake. */
|
|
4156
|
+
function buildMemberSnapshot(member, override) {
|
|
4157
|
+
if (isPointSymbolMember(member)) {
|
|
4158
|
+
const symbol = buildWorkingPointSymbol(member);
|
|
4159
|
+
return {
|
|
4160
|
+
graphic: clonePointSymbol(symbol),
|
|
4161
|
+
render: capability.render(symbol)
|
|
4162
|
+
};
|
|
4163
|
+
}
|
|
4164
|
+
const working = buildWorkingMeasureWith(member, override);
|
|
4165
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
4166
|
+
return {
|
|
4167
|
+
graphic: cloneControlMeasure(committed),
|
|
4168
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
/** The working graphic for member `i` (measure or point symbol), for preview and `workingGraphics`. */
|
|
4172
|
+
function workingGraphicFor(i) {
|
|
4173
|
+
const member = members[i];
|
|
4174
|
+
if (isPointSymbolMember(member)) return buildWorkingPointSymbol(member);
|
|
4175
|
+
return buildWorkingMeasureWith(member, member.optionsOverride);
|
|
4176
|
+
}
|
|
4021
4177
|
let dirty = false;
|
|
4022
4178
|
/**
|
|
4023
4179
|
* 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.
|
|
4180
|
+
* even when every control point still matches edit-start.
|
|
4026
4181
|
*/
|
|
4027
4182
|
let nonGeometryDirty = false;
|
|
4028
4183
|
let dragInFlight = false;
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4184
|
+
const changeEmitter = createChangeEmitter({
|
|
4185
|
+
listenerErrorsMessage: "TransformSession.onChange listener errors",
|
|
4186
|
+
isSettled: () => ctx.settled,
|
|
4187
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
4188
|
+
close: () => {
|
|
4189
|
+
commitClose();
|
|
4190
|
+
}
|
|
4191
|
+
});
|
|
4034
4192
|
const commitEmitter = createCommitEmitter("TransformSession.onCommit listener errors");
|
|
4035
4193
|
const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
|
|
4036
4194
|
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
4037
4195
|
let boxAngle = 0;
|
|
4038
4196
|
function captureHistory() {
|
|
4039
4197
|
return {
|
|
4040
|
-
ids: members.map((member) => member.
|
|
4198
|
+
ids: members.map((member) => member.graphic.id),
|
|
4041
4199
|
points: clonePointsByMember(),
|
|
4042
4200
|
amplifierPlacements: cloneAmplifierPlacementsByMember(),
|
|
4043
4201
|
optionsOverrides: cloneOptionsOverrideByMember(),
|
|
4202
|
+
pointSymbolStates: clonePointSymbolStateByMember(),
|
|
4044
4203
|
sizeAnchor,
|
|
4045
4204
|
nonGeometryDirty,
|
|
4046
4205
|
boxAngle
|
|
@@ -4050,6 +4209,7 @@ function runGroupTransform(opts) {
|
|
|
4050
4209
|
restorePointsByMember(value.points);
|
|
4051
4210
|
applyAmplifierPlacementsByMember(value.amplifierPlacements.map((placements) => placements ? structuredClone(placements) : void 0));
|
|
4052
4211
|
applyOptionsOverrides(value.optionsOverrides);
|
|
4212
|
+
applyPointSymbolStateByMember(value.pointSymbolStates);
|
|
4053
4213
|
sizeAnchor = value.sizeAnchor;
|
|
4054
4214
|
nonGeometryDirty = value.nonGeometryDirty;
|
|
4055
4215
|
boxAngle = value.boxAngle;
|
|
@@ -4058,6 +4218,7 @@ function runGroupTransform(opts) {
|
|
|
4058
4218
|
let groupBox = null;
|
|
4059
4219
|
let ctx;
|
|
4060
4220
|
let driver;
|
|
4221
|
+
let shell;
|
|
4061
4222
|
const dragProjection = {
|
|
4062
4223
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
4063
4224
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
@@ -4068,12 +4229,15 @@ function runGroupTransform(opts) {
|
|
|
4068
4229
|
* Used for both initial and mid-session-added members.
|
|
4069
4230
|
*/
|
|
4070
4231
|
function liftMemberFromGraphics(member) {
|
|
4071
|
-
if (!isOnGraphicsLayer(member.
|
|
4072
|
-
|
|
4073
|
-
|
|
4232
|
+
if (!isOnGraphicsLayer(member.graphic.id)) return;
|
|
4233
|
+
if (isPointSymbol(member.graphic)) adapter.removeFeatures(graphicsLayer, [`${member.graphic.id}:point-symbol`]);
|
|
4234
|
+
else {
|
|
4235
|
+
const ids = renderMeasureForAdapter(adapter, member.graphic, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
4236
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
4237
|
+
}
|
|
4074
4238
|
anyLiftedFromGraphics = true;
|
|
4075
4239
|
}
|
|
4076
|
-
function
|
|
4240
|
+
function pixelsFromFeatures(features) {
|
|
4077
4241
|
const positions = [];
|
|
4078
4242
|
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
4079
4243
|
const pixels = [];
|
|
@@ -4088,7 +4252,7 @@ function runGroupTransform(opts) {
|
|
|
4088
4252
|
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
4089
4253
|
}
|
|
4090
4254
|
/**
|
|
4091
|
-
* Re-fit `groupBox` and every member's `box` from their `
|
|
4255
|
+
* Re-fit `groupBox` and every member's `box` from their `lastBoxPixels`.
|
|
4092
4256
|
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
4093
4257
|
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
4094
4258
|
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
@@ -4100,9 +4264,8 @@ function runGroupTransform(opts) {
|
|
|
4100
4264
|
const padding = interactionStyle?.boxPadding ?? 0;
|
|
4101
4265
|
const allPixels = [];
|
|
4102
4266
|
for (const member of members) {
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
member.box = fitTransformBox(pixels, angle, padding);
|
|
4267
|
+
allPixels.push(...member.lastBoxPixels);
|
|
4268
|
+
member.box = fitTransformBox(member.lastBoxPixels, angle, padding);
|
|
4106
4269
|
}
|
|
4107
4270
|
if (activeBoxDrag) return;
|
|
4108
4271
|
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
@@ -4110,25 +4273,38 @@ function runGroupTransform(opts) {
|
|
|
4110
4273
|
function renderAllPreviews() {
|
|
4111
4274
|
if (ctx.settled) return;
|
|
4112
4275
|
const allFeatures = [];
|
|
4276
|
+
const resources = /* @__PURE__ */ new Map();
|
|
4113
4277
|
members.forEach((member, i) => {
|
|
4114
|
-
const working = workingMeasureFor(i);
|
|
4115
4278
|
let features;
|
|
4279
|
+
let boxPixels = [];
|
|
4116
4280
|
try {
|
|
4117
|
-
|
|
4281
|
+
if (isPointSymbolMember(member)) {
|
|
4282
|
+
const symbol = buildWorkingPointSymbol(member);
|
|
4283
|
+
const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
|
|
4284
|
+
resources.set(plan.resource.id, plan.resource);
|
|
4285
|
+
features = [plan.feature];
|
|
4286
|
+
const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
|
|
4287
|
+
if (footprint) boxPixels = footprint.corners;
|
|
4288
|
+
} else {
|
|
4289
|
+
features = renderMeasureForAdapter(adapter, workingGraphicFor(i), renderOpts).features;
|
|
4290
|
+
boxPixels = pixelsFromFeatures(features);
|
|
4291
|
+
}
|
|
4118
4292
|
} catch {
|
|
4119
4293
|
features = [];
|
|
4294
|
+
boxPixels = [];
|
|
4120
4295
|
}
|
|
4121
|
-
member.
|
|
4296
|
+
member.lastBoxPixels = boxPixels;
|
|
4122
4297
|
allFeatures.push(...features);
|
|
4123
4298
|
});
|
|
4299
|
+
adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
|
|
4124
4300
|
ctx.writeLayer(previewLayer, allFeatures);
|
|
4125
4301
|
refitBoxes();
|
|
4126
4302
|
}
|
|
4127
4303
|
function rescaleOnViewChange() {
|
|
4128
4304
|
if (ctx.settled) return;
|
|
4129
|
-
if (members.some((m) => measureRendersFromViewState({
|
|
4130
|
-
kind: m.
|
|
4131
|
-
options: m.
|
|
4305
|
+
if (members.some((m) => isPointSymbolMember(m) || measureRendersFromViewState({
|
|
4306
|
+
kind: m.graphic.kind,
|
|
4307
|
+
options: m.graphic.options
|
|
4132
4308
|
}))) renderAllPreviews();
|
|
4133
4309
|
else refitBoxes();
|
|
4134
4310
|
renderHandlesLayer();
|
|
@@ -4141,7 +4317,7 @@ function runGroupTransform(opts) {
|
|
|
4141
4317
|
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle(interactionStyle?.boxOutline)));
|
|
4142
4318
|
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
4143
4319
|
}
|
|
4144
|
-
features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.
|
|
4320
|
+
features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.graphic.id, member.box]] : []), dragProjection.fromPixel, handleIdPrefix, interactionStyle?.boxOutline));
|
|
4145
4321
|
ctx.writeLayer(handlesLayer, features);
|
|
4146
4322
|
}
|
|
4147
4323
|
/**
|
|
@@ -4158,9 +4334,9 @@ function runGroupTransform(opts) {
|
|
|
4158
4334
|
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
4159
4335
|
}
|
|
4160
4336
|
function recomputeDirty() {
|
|
4161
|
-
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints,
|
|
4337
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, 1e-9) || isPointSymbolMember(m) && (m.workingSize !== m.editStartSize || m.workingRotation !== m.editStartRotation));
|
|
4162
4338
|
}
|
|
4163
|
-
/** Split `points` (the concatenation of every member's
|
|
4339
|
+
/** Split `points` (the concatenation of every member's anchor points) back per member. */
|
|
4164
4340
|
function applyConcatenatedPoints(points) {
|
|
4165
4341
|
const rounded = roundControlPoints(points);
|
|
4166
4342
|
let offset = 0;
|
|
@@ -4184,6 +4360,23 @@ function runGroupTransform(opts) {
|
|
|
4184
4360
|
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
4185
4361
|
});
|
|
4186
4362
|
}
|
|
4363
|
+
/** Snapshot each point-symbol member's working size/rotation (`null` for measures). */
|
|
4364
|
+
function clonePointSymbolStateByMember() {
|
|
4365
|
+
return members.map((m) => isPointSymbolMember(m) ? {
|
|
4366
|
+
size: m.workingSize,
|
|
4367
|
+
rotation: m.workingRotation
|
|
4368
|
+
} : null);
|
|
4369
|
+
}
|
|
4370
|
+
/** Write a point-symbol size/rotation snapshot back onto the live members (index-aligned). */
|
|
4371
|
+
function applyPointSymbolStateByMember(states) {
|
|
4372
|
+
members.forEach((member, i) => {
|
|
4373
|
+
const state = states[i];
|
|
4374
|
+
if (state && isPointSymbolMember(member)) {
|
|
4375
|
+
member.workingSize = state.size;
|
|
4376
|
+
member.workingRotation = state.rotation;
|
|
4377
|
+
}
|
|
4378
|
+
});
|
|
4379
|
+
}
|
|
4187
4380
|
function cloneAmplifierPlacementsByMember() {
|
|
4188
4381
|
return members.map((member) => member.workingAmplifierPlacements ? structuredClone(member.workingAmplifierPlacements) : void 0);
|
|
4189
4382
|
}
|
|
@@ -4211,13 +4404,15 @@ function runGroupTransform(opts) {
|
|
|
4211
4404
|
}
|
|
4212
4405
|
/**
|
|
4213
4406
|
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
4214
|
-
* anchor) for every member, the moment a group rotate gesture starts —
|
|
4407
|
+
* anchor) for every measure member, the moment a group rotate gesture starts —
|
|
4215
4408
|
* mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
|
|
4216
|
-
* per-member since the group has no single working-options set.
|
|
4409
|
+
* per-member since the group has no single working-options set. Point-symbol
|
|
4410
|
+
* members carry no such options and are skipped.
|
|
4217
4411
|
*/
|
|
4218
4412
|
function freezeOrientationForRotate() {
|
|
4219
4413
|
members.forEach((member, i) => {
|
|
4220
|
-
|
|
4414
|
+
if (isPointSymbolMember(member)) return;
|
|
4415
|
+
const patch = freezeOrientationOptions(member.graphic.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
4221
4416
|
if (patch) member.optionsOverride = {
|
|
4222
4417
|
...member.optionsOverride ?? {},
|
|
4223
4418
|
...patch
|
|
@@ -4225,54 +4420,35 @@ function runGroupTransform(opts) {
|
|
|
4225
4420
|
});
|
|
4226
4421
|
}
|
|
4227
4422
|
/**
|
|
4228
|
-
* Emit a completed gesture
|
|
4229
|
-
*
|
|
4230
|
-
*
|
|
4231
|
-
*
|
|
4423
|
+
* Emit a completed gesture through the shared change-emit protocol
|
|
4424
|
+
* (`createChangeEmitter`, issue #49) — the group mirror of
|
|
4425
|
+
* `edit-controller.ts`'s `emitChange`. Only this controller's variance stays
|
|
4426
|
+
* here: the event payload, the per-member reject rollback, the
|
|
4427
|
+
* membership-identity guard, and the recordable-source history record.
|
|
4232
4428
|
*/
|
|
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 = {
|
|
4429
|
+
function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, source = "gesture", historyLabel = "Transform", previousBoxAngle = boxAngle, previousPointSymbolStateByMember = clonePointSymbolStateByMember()) {
|
|
4430
|
+
const membersAtEmission = members;
|
|
4431
|
+
changeEmitter.emit({
|
|
4239
4432
|
source,
|
|
4240
4433
|
graphics: currentRendered,
|
|
4241
4434
|
previous: previousRendered,
|
|
4242
|
-
session
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4435
|
+
session
|
|
4436
|
+
}, {
|
|
4437
|
+
stillApplicable: () => members === membersAtEmission,
|
|
4438
|
+
rollback() {
|
|
4439
|
+
restorePointsByMember(previousPointsByMember);
|
|
4440
|
+
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
4441
|
+
applyPointSymbolStateByMember(previousPointSymbolStateByMember);
|
|
4442
|
+
sizeAnchor = previousSizeAnchor;
|
|
4443
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
4444
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
4445
|
+
boxAngle = previousBoxAngle;
|
|
4446
|
+
renderAllPreviews();
|
|
4447
|
+
renderHandlesLayer();
|
|
4448
|
+
recomputeDirty();
|
|
4248
4449
|
},
|
|
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");
|
|
4450
|
+
onAccepted: source === "gesture" || source === "programmatic" ? () => history.record(captureHistory(), historyLabel) : void 0
|
|
4451
|
+
});
|
|
4276
4452
|
}
|
|
4277
4453
|
/**
|
|
4278
4454
|
* Commit via `ctx.commit` and emit the close batch to `onCommit` listeners,
|
|
@@ -4296,13 +4472,15 @@ function runGroupTransform(opts) {
|
|
|
4296
4472
|
}
|
|
4297
4473
|
return snapshots;
|
|
4298
4474
|
}
|
|
4299
|
-
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle) {
|
|
4475
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle, nextPointSymbolStateByMember) {
|
|
4300
4476
|
if (ctx.settled) return;
|
|
4301
4477
|
const previousPointsByMember = clonePointsByMember();
|
|
4302
4478
|
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4479
|
+
const previousPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4303
4480
|
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
4304
4481
|
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
4305
4482
|
applyConcatenatedPoints(nextConcatPoints);
|
|
4483
|
+
if (nextPointSymbolStateByMember) applyPointSymbolStateByMember(nextPointSymbolStateByMember);
|
|
4306
4484
|
if (nextAmplifierPlacementsByMember) applyAmplifierPlacementsByMember(nextAmplifierPlacementsByMember);
|
|
4307
4485
|
let currentRendered;
|
|
4308
4486
|
try {
|
|
@@ -4310,6 +4488,7 @@ function runGroupTransform(opts) {
|
|
|
4310
4488
|
} catch (err) {
|
|
4311
4489
|
restorePointsByMember(previousPointsByMember);
|
|
4312
4490
|
applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
|
|
4491
|
+
applyPointSymbolStateByMember(previousPointSymbolStateByMember);
|
|
4313
4492
|
dragInFlight = false;
|
|
4314
4493
|
throw err;
|
|
4315
4494
|
}
|
|
@@ -4317,7 +4496,7 @@ function runGroupTransform(opts) {
|
|
|
4317
4496
|
renderHandlesLayer();
|
|
4318
4497
|
recomputeDirty();
|
|
4319
4498
|
dragInFlight = false;
|
|
4320
|
-
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle);
|
|
4499
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle, previousPointSymbolStateByMember);
|
|
4321
4500
|
}
|
|
4322
4501
|
function setSizeAnchor(next) {
|
|
4323
4502
|
if (ctx.settled || sizeAnchor === next) return;
|
|
@@ -4326,12 +4505,13 @@ function runGroupTransform(opts) {
|
|
|
4326
4505
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
4327
4506
|
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
4328
4507
|
const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4508
|
+
const previousPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4329
4509
|
const previousRendered = rebuildRenderedAll();
|
|
4330
4510
|
sizeAnchor = next;
|
|
4331
4511
|
const currentRendered = rebuildRenderedAll();
|
|
4332
4512
|
nonGeometryDirty = true;
|
|
4333
4513
|
dirty = true;
|
|
4334
|
-
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor");
|
|
4514
|
+
emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor", boxAngle, previousPointSymbolStateByMember);
|
|
4335
4515
|
}
|
|
4336
4516
|
/**
|
|
4337
4517
|
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
@@ -4343,663 +4523,252 @@ function runGroupTransform(opts) {
|
|
|
4343
4523
|
*/
|
|
4344
4524
|
function cancelActiveBoxDrag() {
|
|
4345
4525
|
if (!activeBoxDrag) return;
|
|
4346
|
-
const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
4526
|
+
const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember, preDragPointSymbolStateByMember } = activeBoxDrag;
|
|
4347
4527
|
activeBoxDrag = null;
|
|
4348
4528
|
driver.setDragPanEnabled(true);
|
|
4349
4529
|
applyConcatenatedPoints(preDragPoints);
|
|
4350
4530
|
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4351
4531
|
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
4532
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4352
4533
|
dragInFlight = false;
|
|
4353
4534
|
}
|
|
4354
4535
|
function setGraphics(next) {
|
|
4355
4536
|
if (ctx.settled) return void 0;
|
|
4356
4537
|
const seen = /* @__PURE__ */ new Set();
|
|
4357
|
-
for (const
|
|
4358
|
-
if (seen.has(
|
|
4359
|
-
seen.add(
|
|
4538
|
+
for (const g of next) {
|
|
4539
|
+
if (seen.has(g.id)) throw new TypeError(`TransformSession.setGraphics: duplicate graphic id "${g.id}"`);
|
|
4540
|
+
seen.add(g.id);
|
|
4360
4541
|
}
|
|
4542
|
+
const currentIds = new Set(members.map((m) => m.graphic.id));
|
|
4543
|
+
assertCapabilityFor(next.filter((g) => !currentIds.has(g.id)));
|
|
4361
4544
|
if (next.length === 0) return close();
|
|
4362
4545
|
cancelActiveBoxDrag();
|
|
4363
|
-
const nextIds = new Set(next.map((
|
|
4364
|
-
const currentById = new Map(members.map((m) => [m.
|
|
4546
|
+
const nextIds = new Set(next.map((g) => g.id));
|
|
4547
|
+
const currentById = new Map(members.map((m) => [m.graphic.id, m]));
|
|
4365
4548
|
const removedSnapshots = [];
|
|
4366
|
-
for (const member of members) if (!nextIds.has(member.
|
|
4549
|
+
for (const member of members) if (!nextIds.has(member.graphic.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
4367
4550
|
const nextMembers = [];
|
|
4368
|
-
for (const
|
|
4369
|
-
const existing = currentById.get(
|
|
4551
|
+
for (const graphic of next) {
|
|
4552
|
+
const existing = currentById.get(graphic.id);
|
|
4370
4553
|
if (existing) nextMembers.push(existing);
|
|
4371
4554
|
else {
|
|
4372
|
-
const member = createMemberState(
|
|
4555
|
+
const member = createMemberState(graphic);
|
|
4373
4556
|
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;
|
|
4557
|
+
nextMembers.push(member);
|
|
4804
4558
|
}
|
|
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);
|
|
4559
|
+
}
|
|
4560
|
+
members = nextMembers;
|
|
4561
|
+
renderAllPreviews();
|
|
4562
|
+
renderHandlesLayer();
|
|
4563
|
+
recomputeDirty();
|
|
4564
|
+
const notificationErrors = [];
|
|
4565
|
+
try {
|
|
4566
|
+
history.reset(captureHistory());
|
|
4830
4567
|
} catch (error) {
|
|
4831
|
-
|
|
4568
|
+
notificationErrors.push(error);
|
|
4832
4569
|
}
|
|
4833
|
-
if (
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
boxAngle = previousState.boxAngle;
|
|
4838
|
-
dirty = JSON.stringify(working) !== JSON.stringify(original);
|
|
4839
|
-
renderAll();
|
|
4570
|
+
if (removedSnapshots.length > 0) try {
|
|
4571
|
+
commitEmitter.emit(removedSnapshots);
|
|
4572
|
+
} catch (error) {
|
|
4573
|
+
notificationErrors.push(error);
|
|
4840
4574
|
}
|
|
4841
|
-
if (
|
|
4842
|
-
if (
|
|
4843
|
-
return
|
|
4575
|
+
if (notificationErrors.length === 1) throw notificationErrors[0];
|
|
4576
|
+
if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
|
|
4577
|
+
return removedSnapshots;
|
|
4578
|
+
}
|
|
4579
|
+
function close() {
|
|
4580
|
+
return commitClose();
|
|
4844
4581
|
}
|
|
4845
4582
|
const history = createSessionHistory({
|
|
4846
4583
|
initial: captureHistory(),
|
|
4847
|
-
clone:
|
|
4848
|
-
equals: (a, b) =>
|
|
4849
|
-
isCurrent: (value) =>
|
|
4584
|
+
clone: (value) => structuredClone(value),
|
|
4585
|
+
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
4586
|
+
isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
|
|
4850
4587
|
apply(from, to, source) {
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4588
|
+
if (JSON.stringify(from.ids) !== JSON.stringify(to.ids)) return false;
|
|
4589
|
+
const previousRendered = rebuildRenderedAll();
|
|
4590
|
+
restoreHistory(to);
|
|
4591
|
+
renderAllPreviews();
|
|
4592
|
+
renderHandlesLayer();
|
|
4593
|
+
recomputeDirty();
|
|
4594
|
+
emitChange(previousRendered, from.points, from.amplifierPlacements, from.sizeAnchor, from.nonGeometryDirty, from.optionsOverrides, rebuildRenderedAll(), source, "Transform", from.boxAngle, from.pointSymbolStates);
|
|
4595
|
+
return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
|
|
4856
4596
|
}
|
|
4857
4597
|
});
|
|
4858
4598
|
const session = {
|
|
4859
4599
|
history,
|
|
4860
4600
|
get graphics() {
|
|
4861
|
-
return
|
|
4601
|
+
return members.map((m) => m.graphic);
|
|
4862
4602
|
},
|
|
4863
4603
|
get workingGraphics() {
|
|
4864
|
-
return
|
|
4604
|
+
return members.map((_, i) => workingGraphicFor(i));
|
|
4605
|
+
},
|
|
4606
|
+
controlPointsFor(graphicId) {
|
|
4607
|
+
const member = members.find((m) => m.graphic.id === graphicId);
|
|
4608
|
+
if (!member || isPointSymbolMember(member)) return void 0;
|
|
4609
|
+
return member.workingPoints.map((p) => [...p]);
|
|
4865
4610
|
},
|
|
4866
4611
|
get dirty() {
|
|
4867
4612
|
return dirty;
|
|
4868
4613
|
},
|
|
4869
|
-
|
|
4870
|
-
|
|
4614
|
+
get sizeAnchor() {
|
|
4615
|
+
return sizeAnchor;
|
|
4616
|
+
},
|
|
4617
|
+
setSizeAnchor(next) {
|
|
4618
|
+
setSizeAnchor(next);
|
|
4619
|
+
},
|
|
4620
|
+
setGraphics,
|
|
4621
|
+
close,
|
|
4622
|
+
abort() {
|
|
4623
|
+
if (ctx.settled) return;
|
|
4624
|
+
ctx.fail(new TacticalDrawAbortError("session"));
|
|
4625
|
+
},
|
|
4871
4626
|
onChange(listener) {
|
|
4872
4627
|
if (ctx.settled) return () => {};
|
|
4873
|
-
|
|
4874
|
-
return () => listeners.delete(listener);
|
|
4628
|
+
return changeEmitter.register(listener);
|
|
4875
4629
|
},
|
|
4876
4630
|
onCommit(listener) {
|
|
4877
4631
|
if (ctx.settled) return () => {};
|
|
4878
4632
|
return commitEmitter.register(listener);
|
|
4879
4633
|
},
|
|
4880
|
-
onSettled
|
|
4634
|
+
onSettled(handler) {
|
|
4635
|
+
return settleEmitter.register(handler);
|
|
4636
|
+
}
|
|
4881
4637
|
};
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
if (!
|
|
4886
|
-
const
|
|
4887
|
-
|
|
4638
|
+
let activeBoxDrag = null;
|
|
4639
|
+
/** Working point-symbol state for the given in-flight box, from each member's pre-drag state. */
|
|
4640
|
+
function pointSymbolStateForBox(box) {
|
|
4641
|
+
if (!activeBoxDrag) return clonePointSymbolStateByMember();
|
|
4642
|
+
const { scale, rotation } = boxScaleRotation(activeBoxDrag.fsm.kind, box, activeBoxDrag.preDragBox);
|
|
4643
|
+
return scalePointSymbolStates(activeBoxDrag.preDragPointSymbolStateByMember, scale, rotation);
|
|
4644
|
+
}
|
|
4645
|
+
const onDriverDown = (pixel, info) => {
|
|
4646
|
+
if (ctx.settled || dragInFlight) return;
|
|
4647
|
+
if (!groupBox) return;
|
|
4648
|
+
const tol = info?.hitTolerance ?? 12;
|
|
4649
|
+
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
4650
|
+
if (!boxHit) return;
|
|
4651
|
+
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
4652
|
+
if (boxHit.kind === "rotate") {
|
|
4653
|
+
freezeOrientationForRotate();
|
|
4654
|
+
renderAllPreviews();
|
|
4655
|
+
renderHandlesLayer();
|
|
4656
|
+
}
|
|
4657
|
+
const preDragPoints = concatWorkingPoints();
|
|
4658
|
+
const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
|
|
4659
|
+
const preDragPointSymbolStateByMember = clonePointSymbolStateByMember();
|
|
4660
|
+
activeBoxDrag = {
|
|
4888
4661
|
fsm: beginBoxDrag({
|
|
4889
|
-
kind:
|
|
4662
|
+
kind: boxHit.kind,
|
|
4890
4663
|
downPixel: pixel,
|
|
4891
|
-
points:
|
|
4892
|
-
projection:
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
},
|
|
4896
|
-
box,
|
|
4897
|
-
cornerIndex: hit.kind === "scale" ? hit.index : void 0
|
|
4664
|
+
points: preDragPoints,
|
|
4665
|
+
projection: dragProjection,
|
|
4666
|
+
box: groupBox,
|
|
4667
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
4898
4668
|
}),
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4669
|
+
preDragPoints,
|
|
4670
|
+
preDragBox: groupBox,
|
|
4671
|
+
downPixel: pixel,
|
|
4672
|
+
lastSnapshot: {
|
|
4673
|
+
points: preDragPoints,
|
|
4674
|
+
box: groupBox
|
|
4675
|
+
},
|
|
4676
|
+
preDragOptionsOverrideByMember,
|
|
4677
|
+
preDragAmplifierPlacementsByMember,
|
|
4678
|
+
preDragPointSymbolStateByMember
|
|
4903
4679
|
};
|
|
4680
|
+
dragInFlight = true;
|
|
4904
4681
|
driver.setDragPanEnabled(false);
|
|
4905
4682
|
};
|
|
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();
|
|
4683
|
+
function paintBoxDragSnapshot(pixel, constrainRotation = false) {
|
|
4684
|
+
if (!activeBoxDrag) return;
|
|
4685
|
+
const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
|
|
4686
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
4687
|
+
applyConcatenatedPoints(snapshot.points);
|
|
4688
|
+
applyPointSymbolStateByMember(pointSymbolStateForBox(snapshot.box));
|
|
4689
|
+
applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
|
|
4690
|
+
renderAllPreviews();
|
|
4691
|
+
renderHandlesLayer();
|
|
4692
|
+
recomputeDirty();
|
|
4693
|
+
}
|
|
4694
|
+
const onDriverMove = (pixel, info) => {
|
|
4695
|
+
if (ctx.settled) return;
|
|
4696
|
+
if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
|
|
4923
4697
|
};
|
|
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();
|
|
4698
|
+
const onDriverUp = (pixel, info) => {
|
|
4699
|
+
if (!activeBoxDrag) return;
|
|
4700
|
+
if (ctx.settled) {
|
|
4701
|
+
activeBoxDrag = null;
|
|
4702
|
+
dragInFlight = false;
|
|
4703
|
+
driver.setDragPanEnabled(true);
|
|
4935
4704
|
return;
|
|
4936
4705
|
}
|
|
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;
|
|
4706
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember, preDragPointSymbolStateByMember } = activeBoxDrag;
|
|
4707
|
+
activeBoxDrag = null;
|
|
4708
|
+
driver.setDragPanEnabled(true);
|
|
4709
|
+
if (fsm.kind === "body" && withinClickSlop(pixel, downPixel)) {
|
|
4710
|
+
applyConcatenatedPoints(preDragPoints);
|
|
4711
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4712
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4713
|
+
renderAllPreviews();
|
|
4714
|
+
renderHandlesLayer();
|
|
4715
|
+
recomputeDirty();
|
|
4716
|
+
dragInFlight = false;
|
|
4717
|
+
shell.noteBodyClick();
|
|
4957
4718
|
return;
|
|
4958
4719
|
}
|
|
4959
|
-
|
|
4960
|
-
|
|
4720
|
+
const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
|
|
4721
|
+
const { scale, rotation } = boxScaleRotation(fsm.kind, finalSnapshot.box, preDragBox);
|
|
4722
|
+
const nextPointSymbolStateByMember = scalePointSymbolStates(preDragPointSymbolStateByMember, scale, rotation);
|
|
4723
|
+
applyConcatenatedPoints(preDragPoints);
|
|
4724
|
+
applyPointSymbolStateByMember(preDragPointSymbolStateByMember);
|
|
4725
|
+
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4726
|
+
if (members.some(isPointSymbolMember)) shell.suppressNextClickAway();
|
|
4727
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
4728
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle, nextPointSymbolStateByMember);
|
|
4961
4729
|
};
|
|
4962
|
-
const core =
|
|
4730
|
+
const core = runEditInteraction({
|
|
4963
4731
|
adapter,
|
|
4964
4732
|
signal,
|
|
4965
4733
|
layers: [previewLayer, handlesLayer],
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4734
|
+
graphicsLayer,
|
|
4735
|
+
previewLayer,
|
|
4736
|
+
handlesLayer,
|
|
4737
|
+
closeOnClickAway,
|
|
4738
|
+
onViewChange: rescaleOnViewChange,
|
|
4739
|
+
onDown: onDriverDown,
|
|
4740
|
+
onMove: onDriverMove,
|
|
4741
|
+
onUp: onDriverUp,
|
|
4742
|
+
ownsGraphic: (id) => members.some((m) => m.graphic.id === id),
|
|
4743
|
+
close: () => {
|
|
4744
|
+
commitClose();
|
|
4745
|
+
},
|
|
4746
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
4747
|
+
historySettle: () => history.settle(),
|
|
4748
|
+
facadeOnSettled: onSettled,
|
|
4749
|
+
emitSettle: (settlement) => {
|
|
4750
|
+
if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
|
|
4751
|
+
},
|
|
4752
|
+
extraTeardown: () => {
|
|
4753
|
+
if (activeBoxDrag !== null) {
|
|
4754
|
+
driver.setDragPanEnabled(true);
|
|
4755
|
+
activeBoxDrag = null;
|
|
4756
|
+
dragInFlight = false;
|
|
4973
4757
|
}
|
|
4758
|
+
adapter.reconcileLayerIconResources(previewLayer, []);
|
|
4974
4759
|
},
|
|
4975
|
-
|
|
4760
|
+
onAbort: () => {
|
|
4761
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
4762
|
+
},
|
|
4763
|
+
attach(interaction, createdDriver, createdShell) {
|
|
4976
4764
|
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);
|
|
4765
|
+
driver = createdDriver;
|
|
4766
|
+
shell = createdShell;
|
|
4767
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
4768
|
+
renderAllPreviews();
|
|
4769
|
+
renderHandlesLayer();
|
|
4770
|
+
recomputeDirty();
|
|
4771
|
+
if (onSession) onSession(session);
|
|
5003
4772
|
}
|
|
5004
4773
|
});
|
|
5005
4774
|
return {
|
|
@@ -5008,31 +4777,14 @@ function runGraphicTransform(opts) {
|
|
|
5008
4777
|
return core.settled;
|
|
5009
4778
|
},
|
|
5010
4779
|
get graphicIds() {
|
|
5011
|
-
return
|
|
4780
|
+
return members.map((m) => m.graphic.id);
|
|
5012
4781
|
},
|
|
5013
|
-
abort
|
|
4782
|
+
abort(reason, cause) {
|
|
4783
|
+
core.abort(reason, cause);
|
|
4784
|
+
}
|
|
5014
4785
|
};
|
|
5015
4786
|
}
|
|
5016
4787
|
//#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
4788
|
//#region src/tactical-draw/point-symbol-draw-controller.ts
|
|
5037
4789
|
const PREVIEW_ID = "__td_point_symbol_preview__";
|
|
5038
4790
|
function runPointSymbolDraw(options) {
|
|
@@ -5092,8 +4844,6 @@ function runPointSymbolDraw(options) {
|
|
|
5092
4844
|
}
|
|
5093
4845
|
//#endregion
|
|
5094
4846
|
//#region src/tactical-draw/point-symbol-edit-controller.ts
|
|
5095
|
-
const HIT_TOLERANCE_PX = 12;
|
|
5096
|
-
const CLICK_SLOP_PX = 3;
|
|
5097
4847
|
function normalisePointModes(input) {
|
|
5098
4848
|
const modes = input ?? ["transform"];
|
|
5099
4849
|
for (const mode of modes) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
|
|
@@ -5117,7 +4867,13 @@ function runPointSymbolEdit(opts) {
|
|
|
5117
4867
|
let drag = null;
|
|
5118
4868
|
let ctx;
|
|
5119
4869
|
let driver;
|
|
5120
|
-
|
|
4870
|
+
let shell;
|
|
4871
|
+
const changeEmitter = createChangeEmitter({
|
|
4872
|
+
listenerErrorsMessage: "GraphicEditSession.onChange errors",
|
|
4873
|
+
isSettled: () => ctx.settled,
|
|
4874
|
+
abort: () => ctx.fail(new TacticalDrawAbortError("session")),
|
|
4875
|
+
close: () => ctx.commit(snapshot)
|
|
4876
|
+
});
|
|
5121
4877
|
const commitEmitter = createCommitEmitter("GraphicEditSession.onCommit listener errors");
|
|
5122
4878
|
const settleEmitter = createSettleEmitter("GraphicEditSession.onSettled listener errors");
|
|
5123
4879
|
const captureHistory = () => ({ working: clonePointSymbol(working) });
|
|
@@ -5139,46 +4895,24 @@ function runPointSymbolEdit(opts) {
|
|
|
5139
4895
|
}
|
|
5140
4896
|
ctx.writeLayer(handlesLayer, handles);
|
|
5141
4897
|
};
|
|
5142
|
-
const emitChange = (previousState, source) => {
|
|
4898
|
+
const emitChange = (previousState, source, onAccepted) => {
|
|
5143
4899
|
const previous = {
|
|
5144
4900
|
graphic: clonePointSymbol(previousState.working),
|
|
5145
4901
|
render: capability.render(previousState.working)
|
|
5146
4902
|
};
|
|
5147
|
-
|
|
5148
|
-
let reject = false;
|
|
5149
|
-
let close = false;
|
|
5150
|
-
let abort = false;
|
|
5151
|
-
const event = {
|
|
4903
|
+
return changeEmitter.emit({
|
|
5152
4904
|
source,
|
|
5153
|
-
graphic:
|
|
4905
|
+
graphic: snapshot(),
|
|
5154
4906
|
previous,
|
|
5155
|
-
session
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
4907
|
+
session
|
|
4908
|
+
}, {
|
|
4909
|
+
rollback() {
|
|
4910
|
+
working = clonePointSymbol(previousState.working);
|
|
4911
|
+
dirty = JSON.stringify(working) !== JSON.stringify(symbol);
|
|
4912
|
+
render();
|
|
5161
4913
|
},
|
|
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;
|
|
4914
|
+
onAccepted
|
|
4915
|
+
});
|
|
5182
4916
|
};
|
|
5183
4917
|
const history = createSessionHistory({
|
|
5184
4918
|
initial: captureHistory(),
|
|
@@ -5197,7 +4931,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5197
4931
|
working = clonePointSymbol(next);
|
|
5198
4932
|
dirty = JSON.stringify(working) !== JSON.stringify(symbol);
|
|
5199
4933
|
render();
|
|
5200
|
-
|
|
4934
|
+
emitChange(previousState, source, () => history.record(captureHistory(), label));
|
|
5201
4935
|
};
|
|
5202
4936
|
const session = {
|
|
5203
4937
|
history,
|
|
@@ -5231,8 +4965,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5231
4965
|
},
|
|
5232
4966
|
onChange(listener) {
|
|
5233
4967
|
if (ctx.settled) return () => {};
|
|
5234
|
-
|
|
5235
|
-
return () => listeners.delete(listener);
|
|
4968
|
+
return changeEmitter.register(listener);
|
|
5236
4969
|
},
|
|
5237
4970
|
onCommit(listener) {
|
|
5238
4971
|
if (ctx.settled) return () => {};
|
|
@@ -5245,7 +4978,7 @@ function runPointSymbolEdit(opts) {
|
|
|
5245
4978
|
const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
|
|
5246
4979
|
const onDown = (pixel, info) => {
|
|
5247
4980
|
if (ctx.settled || !box || !modes.includes("transform")) return;
|
|
5248
|
-
const hit = pickBoxHandle(box, pixel, info?.hitTolerance ??
|
|
4981
|
+
const hit = pickBoxHandle(box, pixel, info?.hitTolerance ?? 12);
|
|
5249
4982
|
if (!hit) return;
|
|
5250
4983
|
const anchor = driver.toPixel(working.position);
|
|
5251
4984
|
if (!anchor) return;
|
|
@@ -5292,68 +5025,45 @@ function runPointSymbolEdit(opts) {
|
|
|
5292
5025
|
const currentDrag = drag;
|
|
5293
5026
|
const next = transformed(pixel, info);
|
|
5294
5027
|
drag = null;
|
|
5295
|
-
|
|
5028
|
+
shell.suppressNextClickAway();
|
|
5296
5029
|
driver.setDragPanEnabled(true);
|
|
5297
5030
|
working = clonePointSymbol(currentDrag.start);
|
|
5298
5031
|
if (!next) return;
|
|
5299
|
-
if (currentDrag.kind === "body" &&
|
|
5032
|
+
if (currentDrag.kind === "body" && withinClickSlop(pixel, currentDrag.down)) {
|
|
5300
5033
|
render();
|
|
5301
5034
|
return;
|
|
5302
5035
|
}
|
|
5303
5036
|
apply(next, "gesture", `${currentDrag.kind[0].toUpperCase()}${currentDrag.kind.slice(1)}`);
|
|
5304
5037
|
};
|
|
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({
|
|
5038
|
+
const handle = runEditInteraction({
|
|
5324
5039
|
adapter,
|
|
5325
5040
|
signal,
|
|
5326
5041
|
layers: [previewLayer, handlesLayer],
|
|
5042
|
+
graphicsLayer,
|
|
5043
|
+
previewLayer,
|
|
5044
|
+
handlesLayer,
|
|
5045
|
+
closeOnClickAway,
|
|
5327
5046
|
onViewChange: render,
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
}
|
|
5047
|
+
onDown,
|
|
5048
|
+
onMove,
|
|
5049
|
+
onUp,
|
|
5050
|
+
ownsGraphic: (id) => id === symbol.id,
|
|
5051
|
+
close: () => {
|
|
5052
|
+
ctx.commit(snapshot);
|
|
5335
5053
|
},
|
|
5336
|
-
|
|
5054
|
+
onForeignPick: onClickAwayGraphicsPick,
|
|
5055
|
+
historySettle: () => history.settle(),
|
|
5056
|
+
facadeOnSettled: onSettled,
|
|
5057
|
+
emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
|
|
5058
|
+
extraTeardown: () => adapter.reconcileLayerIconResources(previewLayer, []),
|
|
5059
|
+
onAbort: () => {
|
|
5060
|
+
if (wasOnGraphicsLayer) restoreOriginalToGraphics();
|
|
5061
|
+
},
|
|
5062
|
+
attach(interaction, createdDriver, createdShell) {
|
|
5337
5063
|
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
|
-
});
|
|
5064
|
+
driver = createdDriver;
|
|
5065
|
+
shell = createdShell;
|
|
5355
5066
|
if (wasOnGraphicsLayer) adapter.removeFeatures(graphicsLayer, [`${symbol.id}:point-symbol`]);
|
|
5356
|
-
adapter.on("click", onClick);
|
|
5357
5067
|
render();
|
|
5358
5068
|
onSession?.(session);
|
|
5359
5069
|
}
|
|
@@ -5848,8 +5558,7 @@ var TacticalDraw = class {
|
|
|
5848
5558
|
if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate graphic id "${m.id}"`);
|
|
5849
5559
|
seenIds.add(m.id);
|
|
5850
5560
|
}
|
|
5851
|
-
|
|
5852
|
-
if (hasPointSymbols && !this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
|
|
5561
|
+
if (measures.some(isPointSymbol) && !this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
|
|
5853
5562
|
const preempt = this.guardAndPreempt();
|
|
5854
5563
|
if (preempt) return preempt;
|
|
5855
5564
|
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
@@ -5864,7 +5573,7 @@ var TacticalDraw = class {
|
|
|
5864
5573
|
const extraSettle = () => {
|
|
5865
5574
|
this.activeTransformSessionRef = null;
|
|
5866
5575
|
};
|
|
5867
|
-
|
|
5576
|
+
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
5868
5577
|
adapter: this.adapter,
|
|
5869
5578
|
capability: this.pointSymbols,
|
|
5870
5579
|
graphics: measures,
|
|
@@ -5877,28 +5586,6 @@ var TacticalDraw = class {
|
|
|
5877
5586
|
interactionStyle: mergedInteractionStyle,
|
|
5878
5587
|
onSession: this.trackTransformSession(options?.onSession),
|
|
5879
5588
|
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
5589
|
sizeAnchor: options?.sizeAnchor,
|
|
5903
5590
|
restoreOriginalToGraphics,
|
|
5904
5591
|
isOnGraphicsLayer,
|
|
@@ -5912,12 +5599,15 @@ var TacticalDraw = class {
|
|
|
5912
5599
|
}, extraSettle);
|
|
5913
5600
|
}
|
|
5914
5601
|
/**
|
|
5915
|
-
* One-call selection-sync: mutate a live
|
|
5916
|
-
*
|
|
5917
|
-
*
|
|
5918
|
-
*
|
|
5919
|
-
*
|
|
5920
|
-
*
|
|
5602
|
+
* One-call selection-sync: mutate a live transform's member set in place, or
|
|
5603
|
+
* start a fresh `editMany` when none is live (#191). Any mix of control
|
|
5604
|
+
* measures and point symbols is handled by the one Graphic-typed session
|
|
5605
|
+
* (ADR-0032): a point symbol joins or leaves the live session exactly like a
|
|
5606
|
+
* measure via `setGraphics`, preserving box angle, size anchor, and history
|
|
5607
|
+
* baseline — no preempt-and-restart. A point symbol in the selection with no
|
|
5608
|
+
* point-symbol capability configured throws `PointSymbolCapabilityRequiredError`
|
|
5609
|
+
* synchronously (from `setGraphics` on a live session, or from `editMany` on a
|
|
5610
|
+
* fresh one).
|
|
5921
5611
|
*
|
|
5922
5612
|
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
5923
5613
|
* is cleared inside the settle path, before control returns to host code — so
|
|
@@ -5946,7 +5636,7 @@ var TacticalDraw = class {
|
|
|
5946
5636
|
syncTransformGraphics(graphics, options) {
|
|
5947
5637
|
this.assertAlive();
|
|
5948
5638
|
const session = this.activeTransformSessionRef;
|
|
5949
|
-
if (session
|
|
5639
|
+
if (session) {
|
|
5950
5640
|
if (session.setGraphics(graphics) !== void 0) return;
|
|
5951
5641
|
}
|
|
5952
5642
|
if (graphics.length === 0) {
|