@orbat-mapper/tactical-draw 0.2.0-alpha.1 → 0.2.0-alpha.11

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 +552 -34
  2. package/dist/index.mjs +1738 -354
  3. package/package.json +2 -2
package/dist/index.d.mts CHANGED
@@ -165,6 +165,15 @@ interface MapAdapter {
165
165
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
166
166
  /**
167
167
  * Map Event Listeners
168
+ *
169
+ * Ordering contract: a `"click"` handler and the `onPick` handlers for the
170
+ * same user click MUST observe the pick first — adapters deliver picks and
171
+ * `"click"` from the same underlying engine event, in registration order.
172
+ * The draw/edit controllers register their pick subscriptions before their
173
+ * click handler and read the stashed pick inside it (click-away close /
174
+ * close-then-pick re-dispatch); an adapter that fires `"click"` from an
175
+ * earlier engine event than its picks breaks that contract (e.g. OL
176
+ * `click` vs `singleclick`).
168
177
  */
169
178
  on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
170
179
  off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
@@ -437,9 +446,10 @@ declare function getSizeAnchor(measure: Pick<ControlMeasure, "kind" | "options">
437
446
  //#endregion
438
447
  //#region src/tactical-draw/handles.d.ts
439
448
  /**
440
- * Line-only style surface for the rubber-band guide (ADR-0004). Slot inside
441
- * `InteractionStyle.guide`. Applied as the guide layer's layer style by the
442
- * façade; not per-feature.
449
+ * Line-only style surface for the rubber-band guide (ADR-0004) and the
450
+ * transform-box outline. Slot inside `InteractionStyle.guide` /
451
+ * `InteractionStyle.boxOutline`. Applied as a layer style for the guide, and
452
+ * per-feature (`properties.style`) for the box outline — see ADR-0017.
443
453
  */
444
454
  interface GuideStyle {
445
455
  strokeColor?: string;
@@ -447,25 +457,56 @@ interface GuideStyle {
447
457
  strokeDash?: readonly number[];
448
458
  }
449
459
  /**
450
- * Unified interaction-affordance style surface (ADR-0006). One option object
451
- * for both the guide line and every handle slot. `guide` is delivered as a
452
- * layer style; handle slots ride per-feature on `properties.style`.
460
+ * Unified interaction-affordance style surface (ADR-0006, extended by
461
+ * ADR-0017 for the transform box). One option object for the guide line,
462
+ * the box outline, and every handle slot. `guide` and `boxOutline` are line
463
+ * styles; handle slots ride per-feature on `properties.style`.
453
464
  */
454
465
  interface InteractionStyle {
455
466
  guide?: GuideStyle;
456
467
  vertexHandle?: HandleStyle;
457
468
  midpointHandle?: HandleStyle;
458
- translateHandle?: HandleStyle;
469
+ scaleHandle?: HandleStyle;
459
470
  rotateHandle?: HandleStyle;
471
+ boxOutline?: GuideStyle;
472
+ /**
473
+ * Pixel padding added on every side of the fitted transform box
474
+ * (`fitTransformBox`, ADR-0017) so the box doesn't tightly hug the rendered
475
+ * geometry. Default `0`. A scalar, not a style object, but it participates
476
+ * in the same built-in → façade → per-call resolution slot system as every
477
+ * other `InteractionStyle` field (see `TacticalDraw.resolveInteractionStyle`).
478
+ */
479
+ boxPadding?: number;
460
480
  }
461
481
  /**
462
- * Reserved enum from PRD #40. `reshape`, `translate`, and `rotate` produce
463
- * handles. `delete` is an explicit destructive mode: it renders the vertex
464
- * handles (so they can be tapped to remove) but no midpoint/translate/rotate
465
- * grips, and the edit controller deletes the tapped vertex instead of dragging
466
- * it. `scale` is reserved and throws elsewhere.
482
+ * Reserved enum from PRD #40, replaced under ADR-0017 by the transform-box UI.
483
+ * `reshape` renders vertex/midpoint handles; `transform` renders the oriented
484
+ * selection box (4 corner scale handles + a rotate grip + drag-anywhere
485
+ * body translate) via `transform-box.ts`. The two can be combined freely —
486
+ * single-measure edits show both by default. `delete` is an explicit
487
+ * destructive mode: it renders the vertex handles (so they can be tapped to
488
+ * remove) but no midpoints or box, and the edit controller deletes the tapped
489
+ * vertex instead of dragging it.
490
+ */
491
+ type EditMode = "reshape" | "transform" | "delete";
492
+ //#endregion
493
+ //#region src/tactical-draw/session-events.d.ts
494
+ /**
495
+ * The reason a session (`TransformSession`, `EditSession`, or `DrawSession`)
496
+ * settled, delivered to that session's `onSettled` handler (#191 / #194 /
497
+ * #197). `"close"` covers every commit path of the owning session —
498
+ * `TransformSession.close()` / `EditSession.close()` / `DrawSession.commit()`,
499
+ * a click-away close, a gesture commit (dblclick, a second click/tap on the
500
+ * finishing vertex, a click on another graphics-layer measure), or
501
+ * `TransformSession.setMembers([])`. `"error"` is the commit-time snapshot
502
+ * build throwing (the outer promise rejects with that error, not a
503
+ * `TacticalDrawAbortError`). The remaining values replay
504
+ * `TacticalDrawAbortError.reason` verbatim: `"escape"`, `"signal"`,
505
+ * `"session"` (`session.abort()` / `event.abort()`), `"preempted"`,
506
+ * `"destroyed"`, and `"removed"` (edit / transform only — a measure dropped
507
+ * from `td.render()`'s measure set).
467
508
  */
468
- type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
509
+ type SettleReason = "close" | "error" | TacticalDrawAbortReason;
469
510
  //#endregion
470
511
  //#region src/tactical-draw/edit-session.d.ts
471
512
  /**
@@ -474,7 +515,8 @@ type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
474
515
  *
475
516
  * `measure` is the immutable edit-start input. `controlPoints` is live working
476
517
  * state. After the session settles (`close` / `abort`), every method is a
477
- * silent no-op.
518
+ * silent no-op (except [[onSettled]] — late registration still delivers,
519
+ * asynchronously).
478
520
  */
479
521
  interface EditSession {
480
522
  /** Immutable edit-start input. */
@@ -495,16 +537,20 @@ interface EditSession {
495
537
  */
496
538
  readonly sizeAnchor: SizeAnchor;
497
539
  /**
498
- * Resolve the outer `edit` promise with a `ControlMeasureSnapshot` of the
499
- * current working state.
540
+ * Commit: returns the `ControlMeasureSnapshot` of the current working state
541
+ * and resolves the outer `edit` promise with the same value (the same
542
+ * reference — no double-bake of ground-anchored sizes). Returns `undefined`
543
+ * after the session has settled (idempotent no-op).
500
544
  */
501
- close(): void;
545
+ close(): ControlMeasureSnapshot | undefined;
502
546
  /** Reject the outer `edit` promise with reason `"session"`. */
503
547
  abort(): void;
504
548
  /**
505
- * Switch the active edit-mode set. `"reshape"`, `"translate"`, and `"rotate"`
506
- * produce handles; `"delete"` is exclusive (vertex-tap removal) and collapses
507
- * the set to delete-only. `"scale"` is rejected with `TypeError` per PRD #40.
549
+ * Switch the active edit-mode set. `"reshape"` produces vertex/midpoint
550
+ * handles; `"transform"` renders the oriented selection box (corner scale
551
+ * handles, rotate grip, body translate — ADR-0017); `"delete"` is exclusive
552
+ * (vertex-tap removal) and collapses the set to delete-only. An unknown mode
553
+ * is rejected with `TypeError`.
508
554
  */
509
555
  setModes(modes: readonly EditMode[]): void;
510
556
  /** Shallow-merge per-measure generator options into the live working copy. */
@@ -530,6 +576,47 @@ interface EditSession {
530
576
  * have run.
531
577
  */
532
578
  onChange(listener: (event: EditChangeEvent) => void): () => void;
579
+ /**
580
+ * Subscribe to the committed snapshot — the single delivery channel for the
581
+ * `ControlMeasureSnapshot` this session ever commits (#197, mirroring
582
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
583
+ * within the [[close]] call that produced it (`session.close()`,
584
+ * `event.close()` from [[onChange]], a click-away close, or a click on
585
+ * another graphics-layer measure), with the same reference [[close]]
586
+ * returns and the outer `edit` promise resolves with. Never fires on abort.
587
+ *
588
+ * Listener exceptions are collected; after every listener has run, they are
589
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
590
+ * [[onChange]]. Returns an unsubscribe function; registering after the
591
+ * session has settled returns a no-op unsubscribe and the listener never
592
+ * fires.
593
+ */
594
+ onCommit(handler: (snapshot: ControlMeasureSnapshot) => void): () => void;
595
+ /**
596
+ * Subscribe to the session's settlement — the lifecycle complement of
597
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
598
+ * the session is *over* and why (#197, mirroring
599
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
600
+ * settles by any path, commit or abort, with a `SettleReason`.
601
+ *
602
+ * - On the commit path it fires **after** [[onCommit]]'s emission, so a
603
+ * listener that clears selection state observes a document that already
604
+ * contains the committed snapshot.
605
+ * - On every path it fires **after** the facade has already cleared
606
+ * `td.activeSession` — so fold-then-render works from inside the handler.
607
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
608
+ * no payload; the snapshot was already delivered through [[onCommit]].
609
+ *
610
+ * Registering after the session has already settled is **not** a no-op
611
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
612
+ * asynchronously (on a microtask) exactly once with the settle reason;
613
+ * unsubscribing before that microtask runs cancels the delivery.
614
+ *
615
+ * Listener exceptions are collected; after every listener has run, they are
616
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
617
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
618
+ */
619
+ onSettled(handler: (reason: SettleReason) => void): () => void;
533
620
  }
534
621
  /**
535
622
  * Delivered to `EditSession.onChange` listeners once per completed gesture.
@@ -558,13 +645,205 @@ interface EditChangeEvent {
558
645
  abort(): void;
559
646
  }
560
647
  //#endregion
648
+ //#region src/tactical-draw/transform-session.d.ts
649
+ /**
650
+ * Surface delivered via `td.editMany(measures, opts).onSession` and
651
+ * synchronously available from `td.activeTransformSession` while the group
652
+ * transform is live. The group sibling of `EditSession` (`edit-session.ts`):
653
+ * no reshape, no per-measure `setOptions`/`setStyle` — only the shared
654
+ * transform box's corner-scale / rotate / body-translate gestures, applied to
655
+ * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
656
+ *
657
+ * The member set is mutable via [[setMembers]] (#187): members can join or
658
+ * leave a live session without a session boundary. Because membership changes
659
+ * over the session's lifetime, snapshot arrays are keyed by **measure id**, not
660
+ * by index — a snapshot's identity is `snapshot.measure.id`. `measures` is a
661
+ * live view of the current members' join-time inputs; a `TransformChangeEvent`'s
662
+ * arrays are index-aligned with `session.measures` *as of that emission*. After
663
+ * the session settles (`close` / `abort` / an emptying `setMembers`), every
664
+ * method is a silent no-op (except [[onSettled]] — late registration still
665
+ * delivers, asynchronously).
666
+ */
667
+ interface TransformSession {
668
+ /**
669
+ * Live view of the current members' join-time input measures — a fresh array
670
+ * on each read, in current membership order. Grows/shrinks as [[setMembers]]
671
+ * adds or removes members. Not stable across membership changes: key
672
+ * snapshots by `snapshot.measure.id` rather than relying on index alignment.
673
+ */
674
+ readonly measures: readonly ControlMeasure[];
675
+ /**
676
+ * Live working control points for the member whose id is `measureId`, or
677
+ * `undefined` when no member carries that id. Copy on read.
678
+ */
679
+ controlPointsFor(measureId: string): readonly Position[] | undefined;
680
+ /** `true` when any member's working points differ from its edit-start input beyond tolerance. */
681
+ readonly dirty: boolean;
682
+ /**
683
+ * Live size anchor, applied group-wide (ADR-0020). `"ground"` bakes each
684
+ * member's pixel size to meters on commit; `"screen"` keeps it zoom-aware.
685
+ * Initialised from `EditManyOptions.sizeAnchor` and mutated by [[setSizeAnchor]].
686
+ */
687
+ readonly sizeAnchor: SizeAnchor;
688
+ /** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
689
+ setSizeAnchor(anchor: SizeAnchor): void;
690
+ /**
691
+ * Replace the member set in place — the primitive behind shift-click toggle,
692
+ * marquee replace, and single-edit → group folding, without tearing the
693
+ * session down (#187). Members are matched to the current set by measure id:
694
+ *
695
+ * - **Retained** members (id in both the current and next sets) carry their
696
+ * current working state forward; the passed measure object is ignored for
697
+ * them. Their `dirty` baseline stays their original join-time input, so a
698
+ * session that has already moved things stays dirty across the call.
699
+ * - **Removed** members (id in the current set, absent from the next) are
700
+ * committed at their current working state.
701
+ * - **Added** members (id only in the next set) join at their passed input
702
+ * state; if currently on the graphics layer they are lifted off it exactly
703
+ * like initial members.
704
+ *
705
+ * The transform box re-derives from the next member set at the session's
706
+ * current box angle. Session-scoped state (`sizeAnchor`, box angle,
707
+ * non-geometry dirty) is unchanged.
708
+ *
709
+ * **Return value** — the committed exit snapshots of the members removed *by
710
+ * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
711
+ * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
712
+ * empty array when nothing was removed. The outer `editMany` promise resolves
713
+ * with the snapshots of the members in the set **at close time only**.
714
+ * Removed members are NOT written back to the graphics layer by the
715
+ * controller — the host folds the returned snapshots into its document and
716
+ * calls `td.render()`, the same contract as [[close]]. A removed member's
717
+ * exit snapshot is also delivered through [[onCommit]] — the recommended
718
+ * channel, since it covers this return value and the close-time promise
719
+ * resolution through a single subscription. Returns `undefined` (a silent
720
+ * no-op) after the session has settled.
721
+ *
722
+ * `setMembers([])` on a live session delegates to [[close]]: it commits every
723
+ * current member, resolves the promise with their snapshots, and returns that
724
+ * same array. Duplicate ids in `measures` throw `TypeError` synchronously,
725
+ * without mutating session state.
726
+ */
727
+ setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
728
+ /**
729
+ * Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
730
+ * set at close time) and resolves the outer `editMany` promise with the same
731
+ * value (the same reference — no double-bake of ground-anchored sizes). A
732
+ * member removed mid-session via [[setMembers]] is not included here; its exit
733
+ * snapshot was returned from that `setMembers` call (and delivered to
734
+ * [[onCommit]] at that time). Returns `undefined` after the session has
735
+ * settled (idempotent no-op).
736
+ */
737
+ close(): ControlMeasureSnapshot[] | undefined;
738
+ /** Reject the outer `editMany` promise with reason `"session"`. */
739
+ abort(): void;
740
+ /**
741
+ * Subscribe to working-copy changes. The listener receives a
742
+ * [[TransformChangeEvent]] for each completed group gesture (corner scale,
743
+ * rotate, or body-translate drag end) — never on rubber-band ticks. All
744
+ * currently-registered listeners receive the same event instance, in
745
+ * registration order.
746
+ *
747
+ * Listener exceptions are collected; after every listener has run, they are
748
+ * rethrown (one if single, aggregated otherwise). `event.reject()` /
749
+ * `event.close()` / `event.abort()` take effect only after all listeners
750
+ * have run — mirrors `EditSession.onChange` (issue #49).
751
+ */
752
+ onChange(listener: (event: TransformChangeEvent) => void): () => void;
753
+ /**
754
+ * Subscribe to committed exit-snapshot batches — the single delivery channel
755
+ * for every `ControlMeasureSnapshot[]` this session ever commits (#191). Fires
756
+ * exactly once per batch:
757
+ *
758
+ * - The members [[setMembers]] removes, but only when that call actually
759
+ * removed at least one member.
760
+ * - The close-time members on any commit path: [[close]], `setMembers([])`,
761
+ * a click-away close, or `TransformChangeEvent.close()`.
762
+ *
763
+ * Never fires on an abort/reject path — pair it with [[onSettled]] to also
764
+ * observe abort settles. Fires synchronously from within the
765
+ * `setMembers`/`close` call that produced the batch, before that call
766
+ * returns, and the array delivered here is the same reference that call
767
+ * returns (and, for the close batch, the same reference the `editMany`
768
+ * promise resolves with). This is the recommended single fold path for exit
769
+ * state — subscribe once in `onSession` and every committed snapshot, from
770
+ * either delivery site, arrives here exactly once; the `setMembers` return
771
+ * value and the promise resolution are unchanged and remain valid for
772
+ * backward compatibility.
773
+ *
774
+ * Listener exceptions are collected; after every listener has run, they are
775
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
776
+ * [[onChange]]. Returns an unsubscribe function; registering after the
777
+ * session has settled returns a no-op unsubscribe and the listener never
778
+ * fires.
779
+ */
780
+ onCommit(handler: (snapshots: ControlMeasureSnapshot[]) => void): () => void;
781
+ /**
782
+ * Subscribe to the session's settlement — the lifecycle complement of
783
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
784
+ * the session is *over* and why (#194). Fires exactly once, when the
785
+ * session settles by any path, commit or abort, with a [[SettleReason]].
786
+ *
787
+ * - On commit paths it fires **after** the close batch's `onCommit`
788
+ * emission, so a listener that clears selection state observes a document
789
+ * that already contains the exit snapshots.
790
+ * - On every path it fires **after** the facade has already cleared its
791
+ * slot — `td.activeTransformSession` is `null` and the members are no
792
+ * longer excluded from `td.render()` reconciliation, so fold-then-render
793
+ * works from inside the handler.
794
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
795
+ * no payload; snapshots were already delivered through [[onCommit]].
796
+ *
797
+ * Registering after the session has already settled is **not** a no-op
798
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
799
+ * asynchronously (on a microtask) exactly once with the settle reason;
800
+ * unsubscribing before that microtask runs cancels the delivery.
801
+ *
802
+ * Listener exceptions are collected; after every listener has run, they are
803
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
804
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
805
+ */
806
+ onSettled(handler: (reason: SettleReason) => void): () => void;
807
+ }
808
+ /**
809
+ * Delivered to `TransformSession.onChange` listeners once per completed group
810
+ * gesture. Both `measures` and `previous` are index-aligned with
811
+ * `session.measures` *as of this emission*. Key by `measure.id` rather than
812
+ * relying on index alignment across emissions. A `setMembers` call from within
813
+ * a listener takes effect immediately and supersedes this emission's
814
+ * `reject()` (the rollback baseline no longer matches the member set);
815
+ * `close()` and `abort()` still apply, against the new membership. See
816
+ * `EditChangeEvent` for the identical emission contract this mirrors.
817
+ */
818
+ interface TransformChangeEvent {
819
+ /** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
820
+ readonly measures: ControlMeasureSnapshot[];
821
+ /** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
822
+ readonly previous: ControlMeasureSnapshot[];
823
+ /** The session the event belongs to. */
824
+ readonly session: TransformSession;
825
+ /**
826
+ * Roll every member's working preview back to `previous` after all
827
+ * listeners have run. Multiple calls collapse to a single rollback.
828
+ * Overridden by `close()` or `abort()` if either is also called during this
829
+ * emission, and dropped if a listener called `session.setMembers` (the
830
+ * rollback baseline no longer matches the member set).
831
+ */
832
+ reject(): void;
833
+ /** Shortcut for `event.session.close()`, deferred until the emission ends. */
834
+ close(): void;
835
+ /** Shortcut for `event.session.abort()`, deferred until the emission ends. */
836
+ abort(): void;
837
+ }
838
+ //#endregion
561
839
  //#region src/tactical-draw/session.d.ts
562
840
  /**
563
841
  * Scoped surface delivered via `td.draw({ onSession })` for variable-length
564
842
  * kinds. Also available from `td.activeSession` while the draw is live.
565
843
  *
566
844
  * After the draw has settled — either via `commit()` returning `true` or
567
- * `abort()` resolving — every method is a silent no-op.
845
+ * `abort()` resolving — every method is a silent no-op (except [[onSettled]]
846
+ * — late registration still delivers, asynchronously).
568
847
  */
569
848
  interface DrawSession {
570
849
  /** Live snapshot of the committed control points. Copy on read. */
@@ -586,6 +865,45 @@ interface DrawSession {
586
865
  * are collected and rethrown after all listeners run.
587
866
  */
588
867
  onChange(listener: (session: DrawSession) => void): () => void;
868
+ /**
869
+ * Subscribe to the committed snapshot — the single delivery channel for the
870
+ * `ControlMeasureSnapshot` this session ever commits (#197, mirroring
871
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
872
+ * within the commit that produced it (`session.commit()`, dblclick, or a
873
+ * second click/tap on the finishing vertex), with the same reference the
874
+ * outer `draw` promise resolves with — the committed snapshot, not just the
875
+ * measure. Never fires on abort.
876
+ *
877
+ * Listener exceptions are collected; after every listener has run, they are
878
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
879
+ * [[onChange]]. Returns an unsubscribe function; registering after the
880
+ * session has settled returns a no-op unsubscribe and the listener never
881
+ * fires.
882
+ */
883
+ onCommit(handler: (snapshot: ControlMeasureSnapshot) => void): () => void;
884
+ /**
885
+ * Subscribe to the session's settlement — the lifecycle complement of
886
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
887
+ * the session is *over* and why (#197, mirroring
888
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
889
+ * settles by any path, commit or abort, with a `SettleReason`.
890
+ *
891
+ * - On the commit path it fires **after** [[onCommit]]'s emission.
892
+ * - On every path it fires **after** the facade has already cleared
893
+ * `td.activeSession` — so fold-then-render works from inside the handler.
894
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
895
+ * no payload; the snapshot was already delivered through [[onCommit]].
896
+ *
897
+ * Registering after the session has already settled is **not** a no-op
898
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
899
+ * asynchronously (on a microtask) exactly once with the settle reason;
900
+ * unsubscribing before that microtask runs cancels the delivery.
901
+ *
902
+ * Listener exceptions are collected; after every listener has run, they are
903
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
904
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
905
+ */
906
+ onSettled(handler: (reason: SettleReason) => void): () => void;
589
907
  }
590
908
  //#endregion
591
909
  //#region src/tactical-draw/tactical-draw.d.ts
@@ -637,11 +955,12 @@ interface EditOptions {
637
955
  */
638
956
  closeOnClickAway?: boolean;
639
957
  /**
640
- * Active edit modes. Defaults to `["reshape"]`. `reshape` renders vertex /
641
- * midpoint handles; `translate` renders a grip on the centroid pivot; and
642
- * `rotate` renders a grip offset just outside the shape (so it has a lever
643
- * arm and orbits with the geometry). The three can be combined freely.
644
- * `"scale"` is reserved and throws.
958
+ * Active edit modes. Defaults to `["reshape", "transform"]`. `reshape`
959
+ * renders vertex / midpoint handles; `transform` renders the oriented
960
+ * selection box — 4 corner scale handles, a rotate grip above the top edge,
961
+ * and drag-anywhere-inside-the-box translate (ADR-0017). The two can be
962
+ * combined freely (the default does). `"delete"` is exclusive: it collapses
963
+ * the set to vertex-tap removal only. An unknown mode throws `TypeError`.
645
964
  */
646
965
  modes?: readonly EditMode[];
647
966
  /**
@@ -655,8 +974,10 @@ interface EditOptions {
655
974
  * polyline sourced from the working control points alongside the working
656
975
  * geometry preview. For `geometry === "area"` kinds, a separate
657
976
  * closing-segment `LineString` is also emitted once `workingPoints.length >= 3`.
658
- * The guide is suppressed mechanically when `geometry === "point"` or
659
- * `minCoordinates === maxCoordinates`, regardless of this flag. See ADR-0004.
977
+ * The guide is suppressed mechanically when `geometry === "point"`, and for
978
+ * fixed-length kinds (`minCoordinates === maxCoordinates` or `canonicalPointCount`
979
+ * set) unless the kind's draw rule opts back in via `showGuide`, regardless of
980
+ * this flag. See ADR-0004.
660
981
  */
661
982
  guide?: boolean;
662
983
  /**
@@ -669,6 +990,32 @@ interface EditOptions {
669
990
  */
670
991
  sizeAnchor?: SizeAnchor;
671
992
  }
993
+ /**
994
+ * Options for `TacticalDraw.editMany` — the multi-select group transform
995
+ * (ADR-0017 "Multi-select" slice). Leaner than `EditOptions`: there is no
996
+ * `modes` (a group transform is always the oriented box — no reshape), no
997
+ * `guide` (no rubber-band content for a group), and no per-measure `setOptions`
998
+ * / `setStyle` surface on the resulting session.
999
+ */
1000
+ interface EditManyOptions {
1001
+ signal?: AbortSignal;
1002
+ /**
1003
+ * Called synchronously once when the group transform starts, before any
1004
+ * pointer event is processed. Lets a host wire Done/Cancel/Delete buttons to
1005
+ * the active session.
1006
+ */
1007
+ onSession?: (session: TransformSession) => void;
1008
+ /**
1009
+ * Default `true`. When `true`, a click on empty map (or on a graphics-layer
1010
+ * measure outside the group) closes the active transform. Clicks on preview
1011
+ * / handle features are consumed and keep it open.
1012
+ */
1013
+ closeOnClickAway?: boolean;
1014
+ /** Per-call interaction style override scoped to this transform. See ADR-0006. */
1015
+ interactionStyle?: InteractionStyle;
1016
+ /** Size anchor for the committed measures (ADR-0020), applied group-wide. Default `"ground"`. */
1017
+ sizeAnchor?: SizeAnchor;
1018
+ }
672
1019
  /**
673
1020
  * Draft control measure handed to `TacticalDraw.draw`. `kind` is required up
674
1021
  * front so the façade can resolve metadata, draw rule, and controller choice
@@ -699,8 +1046,10 @@ interface DrawOptions {
699
1046
  * polyline alongside the kind's generator preview. For `geometry === "area"`
700
1047
  * kinds, a separate closing-segment `LineString` is also emitted once the
701
1048
  * total point count (cursor included) is ≥ 3. The guide is suppressed
702
- * mechanically when `geometry === "point"` or `minCoordinates === maxCoordinates`,
703
- * regardless of this flag. See ADR-0004.
1049
+ * mechanically when `geometry === "point"`, and for fixed-length kinds
1050
+ * (`minCoordinates === maxCoordinates` or `canonicalPointCount` set) unless
1051
+ * the kind's draw rule opts back in via `showGuide`, regardless of this
1052
+ * flag. See ADR-0004.
704
1053
  */
705
1054
  guide?: boolean;
706
1055
  /**
@@ -746,6 +1095,20 @@ declare class TacticalDraw {
746
1095
  * `EditHandle` so the controller stays the single owner of edit state.
747
1096
  */
748
1097
  private activeEdit;
1098
+ /**
1099
+ * Tracked alongside `activeInteraction` whenever the interaction is a group
1100
+ * transform (`editMany`). The `render()` / view-change paths need every
1101
+ * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
1102
+ * but for `TransformHandle`'s `measureIds` array.
1103
+ */
1104
+ private activeTransform;
1105
+ /**
1106
+ * The live `TransformSession`, exposed via `activeTransformSession`. Set
1107
+ * directly by `editMany`'s `onSession` wrapper (not routed through
1108
+ * `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
1109
+ * and cleared on settle.
1110
+ */
1111
+ private activeTransformSessionRef;
749
1112
  /**
750
1113
  * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
751
1114
  * façade subscribes once to `adapter.onPick` per layer and routes events
@@ -764,6 +1127,7 @@ declare class TacticalDraw {
764
1127
  private deferredGraphicsPick;
765
1128
  private idCounter;
766
1129
  private editCounter;
1130
+ private transformCounter;
767
1131
  /**
768
1132
  * Snapshot of the host's last `render()` argument. The edit slice uses it
769
1133
  * to recompute the graphics-layer state during a lift / restore — the
@@ -789,6 +1153,21 @@ declare class TacticalDraw {
789
1153
  * as in `render()`; the preview's own view-change subscription rescales it.
790
1154
  */
791
1155
  private readonly handleViewChange;
1156
+ /**
1157
+ * Every measure id currently lifted onto the preview layer by an active
1158
+ * edit or group transform — excluded from `render()` / view-change
1159
+ * reconciliation because the preview layer (not graphics) owns their
1160
+ * working copy. Empty while idle.
1161
+ */
1162
+ private excludedInteractionIds;
1163
+ /**
1164
+ * Live member ids of the active group transform, or `undefined` when none.
1165
+ * Falls back to the session ref because `onSession` fires from inside the
1166
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
1167
+ * the session ref is registered first (`trackTransformSession`), so a
1168
+ * `render()` from inside `onSession` still sees live membership.
1169
+ */
1170
+ private transformMemberIds;
792
1171
  /**
793
1172
  * Reconcile the graphics layer against the authoritative list of measures.
794
1173
  * Idempotent. Throws synchronously on duplicate measure ids *before*
@@ -841,12 +1220,25 @@ declare class TacticalDraw {
841
1220
  * (exposing it via `activeSession`) before the host's own handler runs.
842
1221
  */
843
1222
  private trackSession;
1223
+ /**
1224
+ * Wrap a host `onSession` callback for `editMany`, recording the session on
1225
+ * `activeTransformSessionRef` (exposed via `activeTransformSession`) before
1226
+ * the host's own handler runs. Separate from `trackSession` because
1227
+ * `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
1228
+ */
1229
+ private trackTransformSession;
844
1230
  /**
845
1231
  * Start an interactive draw. Fixed-length kinds commit automatically on the
846
1232
  * last required click. Variable-length kinds surface a `DrawSession` via
847
1233
  * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
848
1234
  * click on the last added control point. All paths reject with
849
1235
  * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
1236
+ *
1237
+ * For a variable-length draw, `DrawSession.onCommit` is the single delivery
1238
+ * channel for the committed snapshot and `DrawSession.onSettled` is the
1239
+ * lifecycle signal for when the session is over and why (#197) — together
1240
+ * they make the returned promise non-load-bearing for hosts that adopt them.
1241
+ * Fixed-length kinds have no session and settle only through the promise.
850
1242
  */
851
1243
  draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<ControlMeasureSnapshot<K>>;
852
1244
  /**
@@ -858,8 +1250,69 @@ declare class TacticalDraw {
858
1250
  * Default click-away (`closeOnClickAway: true`) closes the edit on a click
859
1251
  * outside the preview / handle features. Hosts that own close policy pass
860
1252
  * `false`.
1253
+ *
1254
+ * `EditSession.onCommit` is the single delivery channel for the committed
1255
+ * snapshot and `EditSession.onSettled` is the lifecycle signal for when the
1256
+ * session is over and why (#197) — together they make the returned promise
1257
+ * non-load-bearing for hosts that adopt them.
861
1258
  */
862
1259
  edit(measure: ControlMeasure, options?: EditOptions): Promise<ControlMeasureSnapshot>;
1260
+ /**
1261
+ * Start an interactive group transform (multi-select) over `measures`
1262
+ * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1263
+ * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1264
+ * translate) applies rigidly to all of them at once. Returns a Promise that
1265
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
1266
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
1267
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
1268
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
1269
+ * are id-keyed, not index-aligned with this input. The promise resolves with
1270
+ * the members in the set at close time; a member removed mid-session had its
1271
+ * exit snapshot returned from the `setMembers` call that removed it.
1272
+ *
1273
+ * `measures.length` must be >= 1; a single-element array behaves like an
1274
+ * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1275
+ * any preemption) for an empty array or duplicate ids.
1276
+ *
1277
+ * For a selection that changes over time (shift-click toggle, marquee
1278
+ * replace), prefer [[syncTransformMembers]] over hand-rolling the
1279
+ * live-session-vs-fresh-`editMany` branch, and subscribe to
1280
+ * `TransformSession.onCommit` as the single fold path for exit snapshots
1281
+ * and `TransformSession.onSettled` as the lifecycle signal for when the
1282
+ * session is over and why.
1283
+ */
1284
+ editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<ControlMeasureSnapshot[]>;
1285
+ /**
1286
+ * One-call selection-sync: mutate the live group transform's member set in
1287
+ * place, or start a fresh `editMany` when no transform is live (#191). This
1288
+ * owns the "live session → `setMembers`, else → `editMany`" branch that every
1289
+ * host otherwise hand-rolls against `activeTransformSession`.
1290
+ *
1291
+ * The facade knows session liveness synchronously — `activeTransformSessionRef`
1292
+ * is cleared inside the settle path, before control returns to host code — so
1293
+ * unlike a host's own `activeTransformSession ? setMembers : editMany` check,
1294
+ * this can never observe a stale ref pointing at an already-settled session.
1295
+ * The settled-race fall-through hosts had to hand-roll for that gap is owned
1296
+ * here instead: a `setMembers` call that (implausibly) still returns
1297
+ * `undefined` falls through to a fresh `editMany` rather than silently
1298
+ * dropping the selection.
1299
+ *
1300
+ * `measures: []` closes any live session (committing every member) and is a
1301
+ * no-op while idle. With a live session, `options` is ignored — session-scoped
1302
+ * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
1303
+ * `setMembers`'s contract; `options` applies only when this call starts a
1304
+ * fresh `editMany`. Exit snapshots are delivered via the session's
1305
+ * `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
1306
+ * including an Escape / preempt / destroy abort the host did not itself
1307
+ * initiate — is observed via the session's `onSettled` (#194); together
1308
+ * `onCommit` and `onSettled` make the underlying `editMany` promise
1309
+ * irrelevant to hosts. That promise is owned by the facade; its abort
1310
+ * rejections are swallowed here (non-abort errors surface as unhandled
1311
+ * rejections). Throws `TypeError` synchronously on duplicate ids, from
1312
+ * either path. Starting a fresh transform preempts any other in-flight
1313
+ * draw/edit exactly like `editMany`.
1314
+ */
1315
+ syncTransformMembers(measures: readonly ControlMeasure[], options?: EditManyOptions): void;
863
1316
  /**
864
1317
  * Subscribe to picks on committed control measures.
865
1318
  *
@@ -895,6 +1348,13 @@ declare class TacticalDraw {
895
1348
  * fixed-length draws. Safe to read after `destroy()`.
896
1349
  */
897
1350
  get activeSession(): DrawSession | EditSession | null;
1351
+ /**
1352
+ * The live `editMany` group-transform session, or `null` while idle (and
1353
+ * during any other interaction). Separate from `activeSession` because
1354
+ * `TransformSession` isn't part of its `DrawSession | EditSession` union —
1355
+ * see `activeTransformSessionRef`. Safe to read after `destroy()`.
1356
+ */
1357
+ get activeTransformSession(): TransformSession | null;
898
1358
  /**
899
1359
  * Tear down the façade. Releases only layer slots the façade created; host
900
1360
  * supplied layer ids are left untouched. Safe to call multiple times. After
@@ -926,9 +1386,19 @@ declare class TacticalDraw {
926
1386
  private applyGuideStyle;
927
1387
  private baseGuideStyle;
928
1388
  /**
929
- * Resolve handle slots across built-in → façade → per-call with per-slot,
930
- * per-property shallow merge. Explicit `undefined` at a higher-priority
931
- * layer unsets the corresponding lower-priority field (ADR-0006).
1389
+ * Resolve handle slots (plus `boxOutline`) across built-in → façade →
1390
+ * per-call with per-slot, per-property shallow merge. Explicit `undefined`
1391
+ * at a higher-priority layer unsets the corresponding lower-priority field
1392
+ * (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
1393
+ * `guide` layer-style path) — the edit controller reads it off
1394
+ * `properties.style` on the box outline `LineString` (ADR-0017).
1395
+ *
1396
+ * `boxPadding` is a scalar, not a mergeable style object, so it resolves
1397
+ * separately with the same façade → per-call precedence and the same
1398
+ * explicit-undefined-wins rule: a `boxPadding` key present at a
1399
+ * higher-priority layer (even set to `undefined`) wins over a lower layer's
1400
+ * value. There is no built-in `boxPadding` — an absent value at every layer
1401
+ * falls through to the controllers' own `?? 0` default.
932
1402
  *
933
1403
  * Returns `undefined` only when no slot would carry any value — keeps the
934
1404
  * downstream "no style on the feature" path intact for tests that assert
@@ -947,4 +1417,52 @@ declare class TacticalDrawDestroyedError extends Error {
947
1417
  constructor(message?: string);
948
1418
  }
949
1419
  //#endregion
950
- export { BaseMapAdapter, type CombinedAbort, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, pickHitTolerance, tryControlMeasureIdFromFeature };
1420
+ //#region src/tactical-draw/transform-box.d.ts
1421
+ /**
1422
+ * The Excalidraw/Felt-style transform box: the oriented bounding box drawn
1423
+ * around a selection, with 4 corner scale handles, a rotate grip above the
1424
+ * top edge, and drag-anywhere-inside-the-box translate. This module is the
1425
+ * single source of truth for box *placement* (`fitTransformBox`,
1426
+ * `boxCorners`, `rotateGripPixel`) AND box *picking* (`pointInBox`,
1427
+ * `pickBoxHandle`) — mirroring the ADR-0016 discipline that keeps
1428
+ * `handles.ts` owning vertex/midpoint placement/pick. Everything here is pure
1429
+ * pixel-space math: no projection, no rendering, no session state.
1430
+ *
1431
+ * **Angle convention**: `angle` is in radians, measured in screen pixel space
1432
+ * (y grows downward). A positive angle rotates the box clockwise as drawn on
1433
+ * screen — the same convention `rotatePointsAround` (`handle-drag.ts`) uses,
1434
+ * so box rotation and control-point rotation stay in lockstep.
1435
+ *
1436
+ * **Corner ordering**: `boxCorners` always returns
1437
+ * `[topLeft, topRight, bottomRight, bottomLeft]` in the box's own (unrotated)
1438
+ * local frame, then rotated into place. "Top" means the smaller-y edge before
1439
+ * rotation — i.e. the edge the rotate grip floats above. The order is stable
1440
+ * across calls so a scale handle's `index` (0–3) always means the same
1441
+ * corner, and its opposite corner is always `(index + 2) % 4`.
1442
+ */
1443
+ interface TransformBox {
1444
+ /** Box center, pixel space. */
1445
+ center: PixelCoordinate;
1446
+ /** Half-width along the box's local x-axis (pre-rotation), in pixels. */
1447
+ halfWidth: number;
1448
+ /** Half-height along the box's local y-axis (pre-rotation), in pixels. */
1449
+ halfHeight: number;
1450
+ /** Radians, screen space, positive = clockwise on screen. */
1451
+ angle: number;
1452
+ }
1453
+ //#endregion
1454
+ //#region src/world-wrap.d.ts
1455
+ /**
1456
+ * Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
1457
+ *
1458
+ * Map engines that wrap pointer longitudes into [-180, 180] (OpenLayers'
1459
+ * `toLonLat`, MapLibre's globe unproject) make positions jump ~360° when a
1460
+ * gesture crosses the antimeridian. Adapters use this to keep delivered
1461
+ * longitudes continuous with the view center, and to project coordinates on
1462
+ * the world copy the view is actually looking at.
1463
+ */
1464
+ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1465
+ /** `nearestWorldCopyLng` applied to a lon/lat position. */
1466
+ declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1467
+ //#endregion
1468
+ export { BaseMapAdapter, type CombinedAbort, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SettleReason, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, tryControlMeasureIdFromFeature };