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

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