@orbat-mapper/tactical-draw 0.2.0-alpha.24 → 0.2.0-alpha.26

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