@orbat-mapper/tactical-draw 0.2.0-alpha.4 → 0.2.0-alpha.5

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 +249 -25
  2. package/dist/index.mjs +1359 -335
  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,38 @@ 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.
467
490
  */
468
- type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
491
+ type EditMode = "reshape" | "transform" | "delete";
469
492
  //#endregion
470
493
  //#region src/tactical-draw/edit-session.d.ts
471
494
  /**
@@ -502,9 +525,11 @@ interface EditSession {
502
525
  /** Reject the outer `edit` promise with reason `"session"`. */
503
526
  abort(): void;
504
527
  /**
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.
528
+ * Switch the active edit-mode set. `"reshape"` produces vertex/midpoint
529
+ * handles; `"transform"` renders the oriented selection box (corner scale
530
+ * handles, rotate grip, body translate — ADR-0017); `"delete"` is exclusive
531
+ * (vertex-tap removal) and collapses the set to delete-only. An unknown mode
532
+ * is rejected with `TypeError`.
508
533
  */
509
534
  setModes(modes: readonly EditMode[]): void;
510
535
  /** Shallow-merge per-measure generator options into the live working copy. */
@@ -558,6 +583,84 @@ interface EditChangeEvent {
558
583
  abort(): void;
559
584
  }
560
585
  //#endregion
586
+ //#region src/tactical-draw/transform-session.d.ts
587
+ /**
588
+ * Surface delivered via `td.editMany(measures, opts).onSession` and
589
+ * synchronously available from `td.activeTransformSession` while the group
590
+ * transform is live. The group sibling of `EditSession` (`edit-session.ts`):
591
+ * no reshape, no per-measure `setOptions`/`setStyle` — only the shared
592
+ * transform box's corner-scale / rotate / body-translate gestures, applied to
593
+ * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
594
+ *
595
+ * `measures` is the immutable edit-start input, index-aligned with every
596
+ * snapshot array this session or its change events produce. After the
597
+ * session settles (`close` / `abort`), every method is a silent no-op.
598
+ */
599
+ interface TransformSession {
600
+ /** Immutable edit-start inputs, index-aligned with every snapshot array. */
601
+ readonly measures: readonly ControlMeasure[];
602
+ /**
603
+ * Live working control points for the member whose id is `measureId`, or
604
+ * `undefined` when no member carries that id. Copy on read.
605
+ */
606
+ controlPointsFor(measureId: string): readonly Position[] | undefined;
607
+ /** `true` when any member's working points differ from its edit-start input beyond tolerance. */
608
+ readonly dirty: boolean;
609
+ /**
610
+ * Live size anchor, applied group-wide (ADR-0020). `"ground"` bakes each
611
+ * member's pixel size to meters on commit; `"screen"` keeps it zoom-aware.
612
+ * Initialised from `EditManyOptions.sizeAnchor` and mutated by [[setSizeAnchor]].
613
+ */
614
+ readonly sizeAnchor: SizeAnchor;
615
+ /** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
616
+ setSizeAnchor(anchor: SizeAnchor): void;
617
+ /**
618
+ * Resolve the outer `editMany` promise with index-aligned
619
+ * `ControlMeasureSnapshot[]` of the current working state.
620
+ */
621
+ close(): void;
622
+ /** Reject the outer `editMany` promise with reason `"session"`. */
623
+ abort(): void;
624
+ /**
625
+ * Subscribe to working-copy changes. The listener receives a
626
+ * [[TransformChangeEvent]] for each completed group gesture (corner scale,
627
+ * rotate, or body-translate drag end) — never on rubber-band ticks. All
628
+ * currently-registered listeners receive the same event instance, in
629
+ * registration order.
630
+ *
631
+ * Listener exceptions are collected; after every listener has run, they are
632
+ * rethrown (one if single, aggregated otherwise). `event.reject()` /
633
+ * `event.close()` / `event.abort()` take effect only after all listeners
634
+ * have run — mirrors `EditSession.onChange` (issue #49).
635
+ */
636
+ onChange(listener: (event: TransformChangeEvent) => void): () => void;
637
+ }
638
+ /**
639
+ * Delivered to `TransformSession.onChange` listeners once per completed group
640
+ * gesture. Both `measures` and `previous` are index-aligned with
641
+ * `session.measures`. See `EditChangeEvent` for the identical emission
642
+ * contract this mirrors.
643
+ */
644
+ interface TransformChangeEvent {
645
+ /** Working state after the completed gesture, index-aligned with `session.measures`. */
646
+ readonly measures: ControlMeasureSnapshot[];
647
+ /** State immediately before this gesture, index-aligned with `session.measures`. */
648
+ readonly previous: ControlMeasureSnapshot[];
649
+ /** The session the event belongs to. */
650
+ readonly session: TransformSession;
651
+ /**
652
+ * Roll every member's working preview back to `previous` after all
653
+ * listeners have run. Multiple calls collapse to a single rollback.
654
+ * Overridden by `close()` or `abort()` if either is also called during this
655
+ * emission.
656
+ */
657
+ reject(): void;
658
+ /** Shortcut for `event.session.close()`, deferred until the emission ends. */
659
+ close(): void;
660
+ /** Shortcut for `event.session.abort()`, deferred until the emission ends. */
661
+ abort(): void;
662
+ }
663
+ //#endregion
561
664
  //#region src/tactical-draw/session.d.ts
562
665
  /**
563
666
  * Scoped surface delivered via `td.draw({ onSession })` for variable-length
@@ -637,11 +740,12 @@ interface EditOptions {
637
740
  */
638
741
  closeOnClickAway?: boolean;
639
742
  /**
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.
743
+ * Active edit modes. Defaults to `["reshape", "transform"]`. `reshape`
744
+ * renders vertex / midpoint handles; `transform` renders the oriented
745
+ * selection box — 4 corner scale handles, a rotate grip above the top edge,
746
+ * and drag-anywhere-inside-the-box translate (ADR-0017). The two can be
747
+ * combined freely (the default does). `"delete"` is exclusive: it collapses
748
+ * the set to vertex-tap removal only. An unknown mode throws `TypeError`.
645
749
  */
646
750
  modes?: readonly EditMode[];
647
751
  /**
@@ -669,6 +773,32 @@ interface EditOptions {
669
773
  */
670
774
  sizeAnchor?: SizeAnchor;
671
775
  }
776
+ /**
777
+ * Options for `TacticalDraw.editMany` — the multi-select group transform
778
+ * (ADR-0017 "Multi-select" slice). Leaner than `EditOptions`: there is no
779
+ * `modes` (a group transform is always the oriented box — no reshape), no
780
+ * `guide` (no rubber-band content for a group), and no per-measure `setOptions`
781
+ * / `setStyle` surface on the resulting session.
782
+ */
783
+ interface EditManyOptions {
784
+ signal?: AbortSignal;
785
+ /**
786
+ * Called synchronously once when the group transform starts, before any
787
+ * pointer event is processed. Lets a host wire Done/Cancel/Delete buttons to
788
+ * the active session.
789
+ */
790
+ onSession?: (session: TransformSession) => void;
791
+ /**
792
+ * Default `true`. When `true`, a click on empty map (or on a graphics-layer
793
+ * measure outside the group) closes the active transform. Clicks on preview
794
+ * / handle features are consumed and keep it open.
795
+ */
796
+ closeOnClickAway?: boolean;
797
+ /** Per-call interaction style override scoped to this transform. See ADR-0006. */
798
+ interactionStyle?: InteractionStyle;
799
+ /** Size anchor for the committed measures (ADR-0020), applied group-wide. Default `"ground"`. */
800
+ sizeAnchor?: SizeAnchor;
801
+ }
672
802
  /**
673
803
  * Draft control measure handed to `TacticalDraw.draw`. `kind` is required up
674
804
  * front so the façade can resolve metadata, draw rule, and controller choice
@@ -746,6 +876,20 @@ declare class TacticalDraw {
746
876
  * `EditHandle` so the controller stays the single owner of edit state.
747
877
  */
748
878
  private activeEdit;
879
+ /**
880
+ * Tracked alongside `activeInteraction` whenever the interaction is a group
881
+ * transform (`editMany`). The `render()` / view-change paths need every
882
+ * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
883
+ * but for `TransformHandle`'s `measureIds` array.
884
+ */
885
+ private activeTransform;
886
+ /**
887
+ * The live `TransformSession`, exposed via `activeTransformSession`. Set
888
+ * directly by `editMany`'s `onSession` wrapper (not routed through
889
+ * `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
890
+ * and cleared on settle.
891
+ */
892
+ private activeTransformSessionRef;
749
893
  /**
750
894
  * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
751
895
  * façade subscribes once to `adapter.onPick` per layer and routes events
@@ -764,6 +908,7 @@ declare class TacticalDraw {
764
908
  private deferredGraphicsPick;
765
909
  private idCounter;
766
910
  private editCounter;
911
+ private transformCounter;
767
912
  /**
768
913
  * Snapshot of the host's last `render()` argument. The edit slice uses it
769
914
  * to recompute the graphics-layer state during a lift / restore — the
@@ -789,6 +934,13 @@ declare class TacticalDraw {
789
934
  * as in `render()`; the preview's own view-change subscription rescales it.
790
935
  */
791
936
  private readonly handleViewChange;
937
+ /**
938
+ * Every measure id currently lifted onto the preview layer by an active
939
+ * edit or group transform — excluded from `render()` / view-change
940
+ * reconciliation because the preview layer (not graphics) owns their
941
+ * working copy. Empty while idle.
942
+ */
943
+ private excludedInteractionIds;
792
944
  /**
793
945
  * Reconcile the graphics layer against the authoritative list of measures.
794
946
  * Idempotent. Throws synchronously on duplicate measure ids *before*
@@ -841,6 +993,13 @@ declare class TacticalDraw {
841
993
  * (exposing it via `activeSession`) before the host's own handler runs.
842
994
  */
843
995
  private trackSession;
996
+ /**
997
+ * Wrap a host `onSession` callback for `editMany`, recording the session on
998
+ * `activeTransformSessionRef` (exposed via `activeTransformSession`) before
999
+ * the host's own handler runs. Separate from `trackSession` because
1000
+ * `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
1001
+ */
1002
+ private trackTransformSession;
844
1003
  /**
845
1004
  * Start an interactive draw. Fixed-length kinds commit automatically on the
846
1005
  * last required click. Variable-length kinds surface a `DrawSession` via
@@ -860,6 +1019,20 @@ declare class TacticalDraw {
860
1019
  * `false`.
861
1020
  */
862
1021
  edit(measure: ControlMeasure, options?: EditOptions): Promise<ControlMeasureSnapshot>;
1022
+ /**
1023
+ * Start an interactive group transform (multi-select) over `measures`
1024
+ * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1025
+ * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1026
+ * translate) applies rigidly to all of them at once. Returns a Promise that
1027
+ * resolves with index-aligned `ControlMeasureSnapshot[]` on
1028
+ * `session.close()` and rejects with `TacticalDrawAbortError` on
1029
+ * signal / Escape / destroy / preempt / `session.abort()`.
1030
+ *
1031
+ * `measures.length` must be >= 1; a single-element array behaves like an
1032
+ * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1033
+ * any preemption) for an empty array or duplicate ids.
1034
+ */
1035
+ editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<ControlMeasureSnapshot[]>;
863
1036
  /**
864
1037
  * Subscribe to picks on committed control measures.
865
1038
  *
@@ -895,6 +1068,13 @@ declare class TacticalDraw {
895
1068
  * fixed-length draws. Safe to read after `destroy()`.
896
1069
  */
897
1070
  get activeSession(): DrawSession | EditSession | null;
1071
+ /**
1072
+ * The live `editMany` group-transform session, or `null` while idle (and
1073
+ * during any other interaction). Separate from `activeSession` because
1074
+ * `TransformSession` isn't part of its `DrawSession | EditSession` union —
1075
+ * see `activeTransformSessionRef`. Safe to read after `destroy()`.
1076
+ */
1077
+ get activeTransformSession(): TransformSession | null;
898
1078
  /**
899
1079
  * Tear down the façade. Releases only layer slots the façade created; host
900
1080
  * supplied layer ids are left untouched. Safe to call multiple times. After
@@ -926,9 +1106,19 @@ declare class TacticalDraw {
926
1106
  private applyGuideStyle;
927
1107
  private baseGuideStyle;
928
1108
  /**
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).
1109
+ * Resolve handle slots (plus `boxOutline`) across built-in → façade →
1110
+ * per-call with per-slot, per-property shallow merge. Explicit `undefined`
1111
+ * at a higher-priority layer unsets the corresponding lower-priority field
1112
+ * (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
1113
+ * `guide` layer-style path) — the edit controller reads it off
1114
+ * `properties.style` on the box outline `LineString` (ADR-0017).
1115
+ *
1116
+ * `boxPadding` is a scalar, not a mergeable style object, so it resolves
1117
+ * separately with the same façade → per-call precedence and the same
1118
+ * explicit-undefined-wins rule: a `boxPadding` key present at a
1119
+ * higher-priority layer (even set to `undefined`) wins over a lower layer's
1120
+ * value. There is no built-in `boxPadding` — an absent value at every layer
1121
+ * falls through to the controllers' own `?? 0` default.
932
1122
  *
933
1123
  * Returns `undefined` only when no slot would carry any value — keeps the
934
1124
  * downstream "no style on the feature" path intact for tests that assert
@@ -947,4 +1137,38 @@ declare class TacticalDrawDestroyedError extends Error {
947
1137
  constructor(message?: string);
948
1138
  }
949
1139
  //#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 };
1140
+ //#region src/tactical-draw/transform-box.d.ts
1141
+ /**
1142
+ * The Excalidraw/Felt-style transform box: the oriented bounding box drawn
1143
+ * around a selection, with 4 corner scale handles, a rotate grip above the
1144
+ * top edge, and drag-anywhere-inside-the-box translate. This module is the
1145
+ * single source of truth for box *placement* (`fitTransformBox`,
1146
+ * `boxCorners`, `rotateGripPixel`) AND box *picking* (`pointInBox`,
1147
+ * `pickBoxHandle`) — mirroring the ADR-0016 discipline that keeps
1148
+ * `handles.ts` owning vertex/midpoint placement/pick. Everything here is pure
1149
+ * pixel-space math: no projection, no rendering, no session state.
1150
+ *
1151
+ * **Angle convention**: `angle` is in radians, measured in screen pixel space
1152
+ * (y grows downward). A positive angle rotates the box clockwise as drawn on
1153
+ * screen — the same convention `rotatePointsAround` (`handle-drag.ts`) uses,
1154
+ * so box rotation and control-point rotation stay in lockstep.
1155
+ *
1156
+ * **Corner ordering**: `boxCorners` always returns
1157
+ * `[topLeft, topRight, bottomRight, bottomLeft]` in the box's own (unrotated)
1158
+ * local frame, then rotated into place. "Top" means the smaller-y edge before
1159
+ * rotation — i.e. the edge the rotate grip floats above. The order is stable
1160
+ * across calls so a scale handle's `index` (0–3) always means the same
1161
+ * corner, and its opposite corner is always `(index + 2) % 4`.
1162
+ */
1163
+ interface TransformBox {
1164
+ /** Box center, pixel space. */
1165
+ center: PixelCoordinate;
1166
+ /** Half-width along the box's local x-axis (pre-rotation), in pixels. */
1167
+ halfWidth: number;
1168
+ /** Half-height along the box's local y-axis (pre-rotation), in pixels. */
1169
+ halfHeight: number;
1170
+ /** Radians, screen space, positive = clockwise on screen. */
1171
+ angle: number;
1172
+ }
1173
+ //#endregion
1174
+ 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 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, pickHitTolerance, tryControlMeasureIdFromFeature };