@orbat-mapper/tactical-draw 0.2.0-alpha.7 → 0.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +151 -19
- package/dist/index.mjs +280 -107
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -518,10 +518,12 @@ interface EditSession {
|
|
|
518
518
|
*/
|
|
519
519
|
readonly sizeAnchor: SizeAnchor;
|
|
520
520
|
/**
|
|
521
|
-
*
|
|
522
|
-
*
|
|
521
|
+
* Commit: returns the `ControlMeasureSnapshot` of the current working state
|
|
522
|
+
* and resolves the outer `edit` promise with the same value (the same
|
|
523
|
+
* reference — no double-bake of ground-anchored sizes). Returns `undefined`
|
|
524
|
+
* after the session has settled (idempotent no-op).
|
|
523
525
|
*/
|
|
524
|
-
close():
|
|
526
|
+
close(): ControlMeasureSnapshot | undefined;
|
|
525
527
|
/** Reject the outer `edit` promise with reason `"session"`. */
|
|
526
528
|
abort(): void;
|
|
527
529
|
/**
|
|
@@ -592,12 +594,22 @@ interface EditChangeEvent {
|
|
|
592
594
|
* transform box's corner-scale / rotate / body-translate gestures, applied to
|
|
593
595
|
* every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
|
|
594
596
|
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
* session
|
|
597
|
+
* The member set is mutable via [[setMembers]] (#187): members can join or
|
|
598
|
+
* leave a live session without a session boundary. Because membership changes
|
|
599
|
+
* over the session's lifetime, snapshot arrays are keyed by **measure id**, not
|
|
600
|
+
* by index — a snapshot's identity is `snapshot.measure.id`. `measures` is a
|
|
601
|
+
* live view of the current members' join-time inputs; a `TransformChangeEvent`'s
|
|
602
|
+
* arrays are index-aligned with `session.measures` *as of that emission*. After
|
|
603
|
+
* the session settles (`close` / `abort` / an emptying `setMembers`), every
|
|
604
|
+
* method is a silent no-op.
|
|
598
605
|
*/
|
|
599
606
|
interface TransformSession {
|
|
600
|
-
/**
|
|
607
|
+
/**
|
|
608
|
+
* Live view of the current members' join-time input measures — a fresh array
|
|
609
|
+
* on each read, in current membership order. Grows/shrinks as [[setMembers]]
|
|
610
|
+
* adds or removes members. Not stable across membership changes: key
|
|
611
|
+
* snapshots by `snapshot.measure.id` rather than relying on index alignment.
|
|
612
|
+
*/
|
|
601
613
|
readonly measures: readonly ControlMeasure[];
|
|
602
614
|
/**
|
|
603
615
|
* Live working control points for the member whose id is `measureId`, or
|
|
@@ -615,10 +627,53 @@ interface TransformSession {
|
|
|
615
627
|
/** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
|
|
616
628
|
setSizeAnchor(anchor: SizeAnchor): void;
|
|
617
629
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
|
|
621
|
-
|
|
630
|
+
* Replace the member set in place — the primitive behind shift-click toggle,
|
|
631
|
+
* marquee replace, and single-edit → group folding, without tearing the
|
|
632
|
+
* session down (#187). Members are matched to the current set by measure id:
|
|
633
|
+
*
|
|
634
|
+
* - **Retained** members (id in both the current and next sets) carry their
|
|
635
|
+
* current working state forward; the passed measure object is ignored for
|
|
636
|
+
* them. Their `dirty` baseline stays their original join-time input, so a
|
|
637
|
+
* session that has already moved things stays dirty across the call.
|
|
638
|
+
* - **Removed** members (id in the current set, absent from the next) are
|
|
639
|
+
* committed at their current working state.
|
|
640
|
+
* - **Added** members (id only in the next set) join at their passed input
|
|
641
|
+
* state; if currently on the graphics layer they are lifted off it exactly
|
|
642
|
+
* like initial members.
|
|
643
|
+
*
|
|
644
|
+
* The transform box re-derives from the next member set at the session's
|
|
645
|
+
* current box angle. Session-scoped state (`sizeAnchor`, box angle,
|
|
646
|
+
* non-geometry dirty) is unchanged.
|
|
647
|
+
*
|
|
648
|
+
* **Return value** — the committed exit snapshots of the members removed *by
|
|
649
|
+
* this call*, built exactly like [[close]]'s snapshots (pixel size baked to
|
|
650
|
+
* ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
|
|
651
|
+
* empty array when nothing was removed. The outer `editMany` promise resolves
|
|
652
|
+
* with the snapshots of the members in the set **at close time only**.
|
|
653
|
+
* Removed members are NOT written back to the graphics layer by the
|
|
654
|
+
* controller — the host folds the returned snapshots into its document and
|
|
655
|
+
* calls `td.render()`, the same contract as [[close]]. A removed member's
|
|
656
|
+
* exit snapshot is also delivered through [[onCommit]] — the recommended
|
|
657
|
+
* channel, since it covers this return value and the close-time promise
|
|
658
|
+
* resolution through a single subscription. Returns `undefined` (a silent
|
|
659
|
+
* no-op) after the session has settled.
|
|
660
|
+
*
|
|
661
|
+
* `setMembers([])` on a live session delegates to [[close]]: it commits every
|
|
662
|
+
* current member, resolves the promise with their snapshots, and returns that
|
|
663
|
+
* same array. Duplicate ids in `measures` throw `TypeError` synchronously,
|
|
664
|
+
* without mutating session state.
|
|
665
|
+
*/
|
|
666
|
+
setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
|
|
667
|
+
/**
|
|
668
|
+
* Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
|
|
669
|
+
* set at close time) and resolves the outer `editMany` promise with the same
|
|
670
|
+
* value (the same reference — no double-bake of ground-anchored sizes). A
|
|
671
|
+
* member removed mid-session via [[setMembers]] is not included here; its exit
|
|
672
|
+
* snapshot was returned from that `setMembers` call (and delivered to
|
|
673
|
+
* [[onCommit]] at that time). Returns `undefined` after the session has
|
|
674
|
+
* settled (idempotent no-op).
|
|
675
|
+
*/
|
|
676
|
+
close(): ControlMeasureSnapshot[] | undefined;
|
|
622
677
|
/** Reject the outer `editMany` promise with reason `"session"`. */
|
|
623
678
|
abort(): void;
|
|
624
679
|
/**
|
|
@@ -634,17 +689,48 @@ interface TransformSession {
|
|
|
634
689
|
* have run — mirrors `EditSession.onChange` (issue #49).
|
|
635
690
|
*/
|
|
636
691
|
onChange(listener: (event: TransformChangeEvent) => void): () => void;
|
|
692
|
+
/**
|
|
693
|
+
* Subscribe to committed exit-snapshot batches — the single delivery channel
|
|
694
|
+
* for every `ControlMeasureSnapshot[]` this session ever commits (#191). Fires
|
|
695
|
+
* exactly once per batch:
|
|
696
|
+
*
|
|
697
|
+
* - The members [[setMembers]] removes, but only when that call actually
|
|
698
|
+
* removed at least one member.
|
|
699
|
+
* - The close-time members on any commit path: [[close]], `setMembers([])`,
|
|
700
|
+
* a click-away close, or `TransformChangeEvent.close()`.
|
|
701
|
+
*
|
|
702
|
+
* Never fires on an abort/reject path. Fires synchronously from within the
|
|
703
|
+
* `setMembers`/`close` call that produced the batch, before that call
|
|
704
|
+
* returns, and the array delivered here is the same reference that call
|
|
705
|
+
* returns (and, for the close batch, the same reference the `editMany`
|
|
706
|
+
* promise resolves with). This is the recommended single fold path for exit
|
|
707
|
+
* state — subscribe once in `onSession` and every committed snapshot, from
|
|
708
|
+
* either delivery site, arrives here exactly once; the `setMembers` return
|
|
709
|
+
* value and the promise resolution are unchanged and remain valid for
|
|
710
|
+
* backward compatibility.
|
|
711
|
+
*
|
|
712
|
+
* Listener exceptions are collected; after every listener has run, they are
|
|
713
|
+
* rethrown (one if single, `AggregateError` otherwise) — mirrors
|
|
714
|
+
* [[onChange]]. Returns an unsubscribe function; registering after the
|
|
715
|
+
* session has settled returns a no-op unsubscribe and the listener never
|
|
716
|
+
* fires.
|
|
717
|
+
*/
|
|
718
|
+
onCommit(handler: (snapshots: ControlMeasureSnapshot[]) => void): () => void;
|
|
637
719
|
}
|
|
638
720
|
/**
|
|
639
721
|
* Delivered to `TransformSession.onChange` listeners once per completed group
|
|
640
722
|
* gesture. Both `measures` and `previous` are index-aligned with
|
|
641
|
-
* `session.measures
|
|
642
|
-
*
|
|
723
|
+
* `session.measures` *as of this emission*. Key by `measure.id` rather than
|
|
724
|
+
* relying on index alignment across emissions. A `setMembers` call from within
|
|
725
|
+
* a listener takes effect immediately and supersedes this emission's
|
|
726
|
+
* `reject()` (the rollback baseline no longer matches the member set);
|
|
727
|
+
* `close()` and `abort()` still apply, against the new membership. See
|
|
728
|
+
* `EditChangeEvent` for the identical emission contract this mirrors.
|
|
643
729
|
*/
|
|
644
730
|
interface TransformChangeEvent {
|
|
645
|
-
/** Working state after the completed gesture, index-aligned with `session.measures
|
|
731
|
+
/** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
|
|
646
732
|
readonly measures: ControlMeasureSnapshot[];
|
|
647
|
-
/** State immediately before this gesture, index-aligned with `session.measures
|
|
733
|
+
/** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
|
|
648
734
|
readonly previous: ControlMeasureSnapshot[];
|
|
649
735
|
/** The session the event belongs to. */
|
|
650
736
|
readonly session: TransformSession;
|
|
@@ -652,7 +738,8 @@ interface TransformChangeEvent {
|
|
|
652
738
|
* Roll every member's working preview back to `previous` after all
|
|
653
739
|
* listeners have run. Multiple calls collapse to a single rollback.
|
|
654
740
|
* Overridden by `close()` or `abort()` if either is also called during this
|
|
655
|
-
* emission.
|
|
741
|
+
* emission, and dropped if a listener called `session.setMembers` (the
|
|
742
|
+
* rollback baseline no longer matches the member set).
|
|
656
743
|
*/
|
|
657
744
|
reject(): void;
|
|
658
745
|
/** Shortcut for `event.session.close()`, deferred until the emission ends. */
|
|
@@ -945,6 +1032,14 @@ declare class TacticalDraw {
|
|
|
945
1032
|
* working copy. Empty while idle.
|
|
946
1033
|
*/
|
|
947
1034
|
private excludedInteractionIds;
|
|
1035
|
+
/**
|
|
1036
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
1037
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
1038
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
1039
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
1040
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
1041
|
+
*/
|
|
1042
|
+
private transformMemberIds;
|
|
948
1043
|
/**
|
|
949
1044
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
950
1045
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
@@ -1028,15 +1123,52 @@ declare class TacticalDraw {
|
|
|
1028
1123
|
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
1029
1124
|
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
1030
1125
|
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
1031
|
-
* resolves with
|
|
1032
|
-
* `
|
|
1033
|
-
*
|
|
1126
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
1127
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
1128
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
1129
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
1130
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
1131
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
1132
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
1034
1133
|
*
|
|
1035
1134
|
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
1036
1135
|
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
1037
1136
|
* any preemption) for an empty array or duplicate ids.
|
|
1137
|
+
*
|
|
1138
|
+
* For a selection that changes over time (shift-click toggle, marquee
|
|
1139
|
+
* replace), prefer [[syncTransformMembers]] over hand-rolling the
|
|
1140
|
+
* live-session-vs-fresh-`editMany` branch, and subscribe to
|
|
1141
|
+
* `TransformSession.onCommit` as the single fold path for exit snapshots.
|
|
1038
1142
|
*/
|
|
1039
1143
|
editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<ControlMeasureSnapshot[]>;
|
|
1144
|
+
/**
|
|
1145
|
+
* One-call selection-sync: mutate the live group transform's member set in
|
|
1146
|
+
* place, or start a fresh `editMany` when no transform is live (#191). This
|
|
1147
|
+
* owns the "live session → `setMembers`, else → `editMany`" branch that every
|
|
1148
|
+
* host otherwise hand-rolls against `activeTransformSession`.
|
|
1149
|
+
*
|
|
1150
|
+
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
1151
|
+
* is cleared inside the settle path, before control returns to host code — so
|
|
1152
|
+
* unlike a host's own `activeTransformSession ? setMembers : editMany` check,
|
|
1153
|
+
* this can never observe a stale ref pointing at an already-settled session.
|
|
1154
|
+
* The settled-race fall-through hosts had to hand-roll for that gap is owned
|
|
1155
|
+
* here instead: a `setMembers` call that (implausibly) still returns
|
|
1156
|
+
* `undefined` falls through to a fresh `editMany` rather than silently
|
|
1157
|
+
* dropping the selection.
|
|
1158
|
+
*
|
|
1159
|
+
* `measures: []` closes any live session (committing every member) and is a
|
|
1160
|
+
* no-op while idle. With a live session, `options` is ignored — session-scoped
|
|
1161
|
+
* state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
|
|
1162
|
+
* `setMembers`'s contract; `options` applies only when this call starts a
|
|
1163
|
+
* fresh `editMany`. Exit snapshots are delivered via the session's
|
|
1164
|
+
* `onCommit` — subscribe once in `options.onSession`. The underlying
|
|
1165
|
+
* `editMany` promise is owned by the facade; its abort rejections are
|
|
1166
|
+
* swallowed here (non-abort errors surface as unhandled rejections). Throws
|
|
1167
|
+
* `TypeError` synchronously on duplicate ids, from either path. Starting a
|
|
1168
|
+
* fresh transform preempts any other in-flight draw/edit exactly like
|
|
1169
|
+
* `editMany`.
|
|
1170
|
+
*/
|
|
1171
|
+
syncTransformMembers(measures: readonly ControlMeasure[], options?: EditManyOptions): void;
|
|
1040
1172
|
/**
|
|
1041
1173
|
* Subscribe to picks on committed control measures.
|
|
1042
1174
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -699,7 +699,7 @@ function runInteraction(spec) {
|
|
|
699
699
|
clearLayers();
|
|
700
700
|
}
|
|
701
701
|
function commit(produce) {
|
|
702
|
-
if (settled) return;
|
|
702
|
+
if (settled) return void 0;
|
|
703
703
|
let value;
|
|
704
704
|
try {
|
|
705
705
|
value = produce();
|
|
@@ -714,6 +714,7 @@ function runInteraction(spec) {
|
|
|
714
714
|
teardown();
|
|
715
715
|
resolveFn(value);
|
|
716
716
|
if (onSettled) onSettled();
|
|
717
|
+
return value;
|
|
717
718
|
}
|
|
718
719
|
function fail(error) {
|
|
719
720
|
if (settled) return;
|
|
@@ -2259,8 +2260,7 @@ function runEdit(opts) {
|
|
|
2259
2260
|
return sizeAnchor;
|
|
2260
2261
|
},
|
|
2261
2262
|
close() {
|
|
2262
|
-
|
|
2263
|
-
ctx.commit(rebuildRendered);
|
|
2263
|
+
return ctx.commit(rebuildRendered);
|
|
2264
2264
|
},
|
|
2265
2265
|
abort() {
|
|
2266
2266
|
if (ctx.settled) return;
|
|
@@ -2664,49 +2664,67 @@ const CLICK_SLOP_PX = 3;
|
|
|
2664
2664
|
* thin outline (its own fit at the same angle)
|
|
2665
2665
|
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
2666
2666
|
* contracts as `runEdit`, scoped to the group
|
|
2667
|
+
* - membership is mutable via `session.setMembers` (#187): members join/leave
|
|
2668
|
+
* a live session without a session boundary
|
|
2667
2669
|
*/
|
|
2668
2670
|
function runGroupTransform(opts) {
|
|
2669
|
-
const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics,
|
|
2671
|
+
const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
2670
2672
|
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2671
|
-
const memberIds = new Set(measures.map((m) => m.id));
|
|
2672
2673
|
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2674
|
+
/** Build a fresh working record for `measure`, snapshotting its edit-start points. */
|
|
2675
|
+
function createMemberState(measure) {
|
|
2676
|
+
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2677
|
+
return {
|
|
2678
|
+
measure,
|
|
2679
|
+
editStartPoints,
|
|
2680
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
2681
|
+
optionsOverride: null,
|
|
2682
|
+
box: null,
|
|
2683
|
+
lastPreviewFeatures: []
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
/** The single mutable source of truth for the current member set. */
|
|
2687
|
+
let members = measures.map(createMemberState);
|
|
2676
2688
|
/**
|
|
2677
|
-
*
|
|
2678
|
-
*
|
|
2679
|
-
*
|
|
2680
|
-
* Battle Position's echelon anchor) the moment a group rotate gesture
|
|
2681
|
-
* starts (ADR-0023) — there is no public per-member `setOptions` here, and
|
|
2682
|
-
* the "no per-measure setOptions" contract for the group session still
|
|
2683
|
-
* holds; a host cannot reach this array.
|
|
2689
|
+
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
2690
|
+
* mid-session `setMembers` add). Gates the abort-path restore so a session
|
|
2691
|
+
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
2684
2692
|
*/
|
|
2685
|
-
let
|
|
2686
|
-
/**
|
|
2687
|
-
function
|
|
2688
|
-
|
|
2689
|
-
if (!override) return measures[i].options;
|
|
2693
|
+
let anyLiftedFromGraphics = false;
|
|
2694
|
+
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
2695
|
+
function mergeOptions(base, override) {
|
|
2696
|
+
if (!override) return base;
|
|
2690
2697
|
return {
|
|
2691
|
-
...
|
|
2698
|
+
...base ?? {},
|
|
2692
2699
|
...override
|
|
2693
2700
|
};
|
|
2694
2701
|
}
|
|
2695
|
-
/** Effective options for member `i`: its own options merged with
|
|
2702
|
+
/** Effective options for member `i`: its own options merged with its *live* override. */
|
|
2696
2703
|
function effectiveOptions(i) {
|
|
2697
|
-
|
|
2704
|
+
const member = members[i];
|
|
2705
|
+
return mergeOptions(member.measure.options, member.optionsOverride);
|
|
2698
2706
|
}
|
|
2699
|
-
/**
|
|
2700
|
-
function
|
|
2707
|
+
/** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
|
|
2708
|
+
function buildMemberSnapshot(member, override) {
|
|
2709
|
+
const working = {
|
|
2710
|
+
...member.measure,
|
|
2711
|
+
options: mergeOptions(member.measure.options, override),
|
|
2712
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2713
|
+
};
|
|
2714
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
2701
2715
|
return {
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
controlPoints: workingPointsByMember[i].map((p) => [...p])
|
|
2716
|
+
measure: cloneControlMeasure(committed),
|
|
2717
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
2705
2718
|
};
|
|
2706
2719
|
}
|
|
2707
2720
|
/** The working measure for member `i`: original measure, live effective options, live working points. */
|
|
2708
2721
|
function workingMeasureFor(i) {
|
|
2709
|
-
|
|
2722
|
+
const member = members[i];
|
|
2723
|
+
return {
|
|
2724
|
+
...member.measure,
|
|
2725
|
+
options: effectiveOptions(i),
|
|
2726
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2727
|
+
};
|
|
2710
2728
|
}
|
|
2711
2729
|
let dirty = false;
|
|
2712
2730
|
/**
|
|
@@ -2719,26 +2737,27 @@ function runGroupTransform(opts) {
|
|
|
2719
2737
|
/** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
|
|
2720
2738
|
let suppressClickAway = false;
|
|
2721
2739
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
2740
|
+
const commitListeners = /* @__PURE__ */ new Set();
|
|
2722
2741
|
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
2723
2742
|
let boxAngle = 0;
|
|
2724
2743
|
/** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
|
|
2725
2744
|
let groupBox = null;
|
|
2726
|
-
/** Each member's own fitted box (same angle as `groupBox`), index-aligned with `measures`. */
|
|
2727
|
-
let memberBoxes = measures.map(() => null);
|
|
2728
|
-
/** The features written to the preview layer for each member on the last paint. */
|
|
2729
|
-
let lastPreviewFeaturesByMember = measures.map(() => []);
|
|
2730
2745
|
let ctx;
|
|
2731
2746
|
let driver;
|
|
2732
2747
|
const dragProjection = {
|
|
2733
2748
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2734
2749
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2735
2750
|
};
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2751
|
+
/**
|
|
2752
|
+
* Lift `member` off the graphics layer when the host reports it currently
|
|
2753
|
+
* there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
|
|
2754
|
+
* Used for both initial and mid-session-added members.
|
|
2755
|
+
*/
|
|
2756
|
+
function liftMemberFromGraphics(member) {
|
|
2757
|
+
if (!isOnGraphicsLayer(member.measure.id)) return;
|
|
2758
|
+
const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
2759
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
2760
|
+
anyLiftedFromGraphics = true;
|
|
2742
2761
|
}
|
|
2743
2762
|
function pixelsFor(features) {
|
|
2744
2763
|
const positions = [];
|
|
@@ -2755,7 +2774,7 @@ function runGroupTransform(opts) {
|
|
|
2755
2774
|
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
2756
2775
|
}
|
|
2757
2776
|
/**
|
|
2758
|
-
* Re-fit `groupBox` and every `
|
|
2777
|
+
* Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
|
|
2759
2778
|
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
2760
2779
|
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
2761
2780
|
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
@@ -2766,18 +2785,18 @@ function runGroupTransform(opts) {
|
|
|
2766
2785
|
const angle = currentAngle();
|
|
2767
2786
|
const padding = interactionStyle?.boxPadding ?? 0;
|
|
2768
2787
|
const allPixels = [];
|
|
2769
|
-
|
|
2770
|
-
const pixels = pixelsFor(
|
|
2788
|
+
for (const member of members) {
|
|
2789
|
+
const pixels = pixelsFor(member.lastPreviewFeatures);
|
|
2771
2790
|
allPixels.push(...pixels);
|
|
2772
|
-
|
|
2773
|
-
}
|
|
2791
|
+
member.box = fitTransformBox(pixels, angle, padding);
|
|
2792
|
+
}
|
|
2774
2793
|
if (activeBoxDrag) return;
|
|
2775
2794
|
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
2776
2795
|
}
|
|
2777
2796
|
function renderAllPreviews() {
|
|
2778
2797
|
if (ctx.settled) return;
|
|
2779
2798
|
const allFeatures = [];
|
|
2780
|
-
|
|
2799
|
+
members.forEach((member, i) => {
|
|
2781
2800
|
const working = workingMeasureFor(i);
|
|
2782
2801
|
let features;
|
|
2783
2802
|
try {
|
|
@@ -2785,17 +2804,17 @@ function runGroupTransform(opts) {
|
|
|
2785
2804
|
} catch {
|
|
2786
2805
|
features = [];
|
|
2787
2806
|
}
|
|
2807
|
+
member.lastPreviewFeatures = features;
|
|
2788
2808
|
allFeatures.push(...features);
|
|
2789
|
-
return features;
|
|
2790
2809
|
});
|
|
2791
2810
|
ctx.writeLayer(previewLayer, allFeatures);
|
|
2792
2811
|
refitBoxes();
|
|
2793
2812
|
}
|
|
2794
2813
|
function rescaleOnViewChange() {
|
|
2795
2814
|
if (ctx.settled) return;
|
|
2796
|
-
if (
|
|
2797
|
-
kind: m.kind,
|
|
2798
|
-
options: m.options
|
|
2815
|
+
if (members.some((m) => measureUsesAdapterResolution({
|
|
2816
|
+
kind: m.measure.kind,
|
|
2817
|
+
options: m.measure.options
|
|
2799
2818
|
}))) renderAllPreviews();
|
|
2800
2819
|
else refitBoxes();
|
|
2801
2820
|
renderHandlesLayer();
|
|
@@ -2821,59 +2840,62 @@ function runGroupTransform(opts) {
|
|
|
2821
2840
|
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
|
|
2822
2841
|
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2823
2842
|
}
|
|
2824
|
-
|
|
2825
|
-
const memberBox =
|
|
2826
|
-
if (!memberBox)
|
|
2827
|
-
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
|
|
2828
|
-
}
|
|
2843
|
+
for (const member of members) {
|
|
2844
|
+
const memberBox = member.box;
|
|
2845
|
+
if (!memberBox) continue;
|
|
2846
|
+
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
|
|
2847
|
+
}
|
|
2829
2848
|
ctx.writeLayer(handlesLayer, features);
|
|
2830
2849
|
}
|
|
2831
2850
|
/**
|
|
2832
2851
|
* Builds every member's committed snapshot against a given override array
|
|
2833
|
-
* rather than the live
|
|
2834
|
-
*
|
|
2835
|
-
*
|
|
2852
|
+
* rather than the live per-member override — used to render a gesture's
|
|
2853
|
+
* pre-freeze "previous" snapshot when a rotate has already mutated the live
|
|
2854
|
+
* overrides by the time the commit runs. Index-aligned with `members`, which
|
|
2855
|
+
* is sound because membership cannot change within a gesture emission.
|
|
2836
2856
|
*/
|
|
2837
2857
|
function rebuildRenderedAllWith(overrides) {
|
|
2838
|
-
return
|
|
2839
|
-
const working = workingMeasureForWith(i, overrides);
|
|
2840
|
-
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
2841
|
-
return {
|
|
2842
|
-
measure: cloneControlMeasure(committed),
|
|
2843
|
-
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
2844
|
-
};
|
|
2845
|
-
});
|
|
2858
|
+
return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
|
|
2846
2859
|
}
|
|
2847
2860
|
function rebuildRenderedAll() {
|
|
2848
|
-
return
|
|
2861
|
+
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
2849
2862
|
}
|
|
2850
2863
|
function recomputeDirty() {
|
|
2851
|
-
dirty = nonGeometryDirty ||
|
|
2864
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
|
|
2852
2865
|
}
|
|
2853
|
-
/** Split `points` (the concatenation of every member's control points) back per member
|
|
2866
|
+
/** Split `points` (the concatenation of every member's control points) back per member. */
|
|
2854
2867
|
function applyConcatenatedPoints(points) {
|
|
2855
2868
|
const rounded = roundControlPoints(points);
|
|
2856
2869
|
let offset = 0;
|
|
2857
|
-
for (
|
|
2858
|
-
const c =
|
|
2870
|
+
for (const member of members) {
|
|
2871
|
+
const c = member.workingPoints.length;
|
|
2859
2872
|
const slice = rounded.slice(offset, offset + c);
|
|
2860
|
-
|
|
2873
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...slice);
|
|
2861
2874
|
offset += c;
|
|
2862
2875
|
}
|
|
2863
2876
|
}
|
|
2864
2877
|
function concatWorkingPoints() {
|
|
2865
2878
|
const out = [];
|
|
2866
|
-
for (const
|
|
2879
|
+
for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
|
|
2867
2880
|
return out;
|
|
2868
2881
|
}
|
|
2869
2882
|
function clonePointsByMember() {
|
|
2870
|
-
return
|
|
2883
|
+
return members.map((m) => m.workingPoints.map((p) => [...p]));
|
|
2871
2884
|
}
|
|
2872
2885
|
function restorePointsByMember(snapshot) {
|
|
2873
|
-
|
|
2886
|
+
members.forEach((member, i) => {
|
|
2887
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
2888
|
+
});
|
|
2874
2889
|
}
|
|
2875
2890
|
function cloneOptionsOverrideByMember() {
|
|
2876
|
-
return
|
|
2891
|
+
return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
|
|
2892
|
+
}
|
|
2893
|
+
/** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
|
|
2894
|
+
function applyOptionsOverrides(overrides) {
|
|
2895
|
+
members.forEach((member, i) => {
|
|
2896
|
+
const o = overrides[i];
|
|
2897
|
+
member.optionsOverride = o ? { ...o } : null;
|
|
2898
|
+
});
|
|
2877
2899
|
}
|
|
2878
2900
|
/**
|
|
2879
2901
|
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
@@ -2882,10 +2904,10 @@ function runGroupTransform(opts) {
|
|
|
2882
2904
|
* per-member since the group has no single working-options set.
|
|
2883
2905
|
*/
|
|
2884
2906
|
function freezeOrientationForRotate() {
|
|
2885
|
-
|
|
2886
|
-
const patch = freezeOrientationOptions(measure.kind,
|
|
2887
|
-
if (patch)
|
|
2888
|
-
...
|
|
2907
|
+
members.forEach((member, i) => {
|
|
2908
|
+
const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
2909
|
+
if (patch) member.optionsOverride = {
|
|
2910
|
+
...member.optionsOverride ?? {},
|
|
2889
2911
|
...patch
|
|
2890
2912
|
};
|
|
2891
2913
|
});
|
|
@@ -2915,6 +2937,7 @@ function runGroupTransform(opts) {
|
|
|
2915
2937
|
abortRequested = true;
|
|
2916
2938
|
}
|
|
2917
2939
|
};
|
|
2940
|
+
const membersAtEmission = members;
|
|
2918
2941
|
const errors = [];
|
|
2919
2942
|
for (const listener of [...changeListeners]) try {
|
|
2920
2943
|
listener(event);
|
|
@@ -2922,12 +2945,12 @@ function runGroupTransform(opts) {
|
|
|
2922
2945
|
errors.push(err);
|
|
2923
2946
|
}
|
|
2924
2947
|
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
2925
|
-
else if (closeRequested)
|
|
2926
|
-
else if (rejectRequested && !ctx.settled) {
|
|
2948
|
+
else if (closeRequested) commitClose();
|
|
2949
|
+
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
2927
2950
|
restorePointsByMember(previousPointsByMember);
|
|
2928
2951
|
sizeAnchor = previousSizeAnchor;
|
|
2929
2952
|
nonGeometryDirty = previousNonGeometryDirty;
|
|
2930
|
-
|
|
2953
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
2931
2954
|
renderAllPreviews();
|
|
2932
2955
|
renderHandlesLayer();
|
|
2933
2956
|
recomputeDirty();
|
|
@@ -2935,6 +2958,35 @@ function runGroupTransform(opts) {
|
|
|
2935
2958
|
if (errors.length === 1) throw errors[0];
|
|
2936
2959
|
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
|
|
2937
2960
|
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Deliver a committed exit-snapshot batch to every `onCommit` listener — the
|
|
2963
|
+
* single delivery channel for both the `setMembers`-removed batch and the
|
|
2964
|
+
* close-time batch (#191). Same exception-collection contract as `emitChange`.
|
|
2965
|
+
*/
|
|
2966
|
+
function emitCommit(snapshots) {
|
|
2967
|
+
const errors = [];
|
|
2968
|
+
for (const listener of [...commitListeners]) try {
|
|
2969
|
+
listener(snapshots);
|
|
2970
|
+
} catch (err) {
|
|
2971
|
+
errors.push(err);
|
|
2972
|
+
}
|
|
2973
|
+
if (errors.length === 1) throw errors[0];
|
|
2974
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onCommit listener errors");
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Commit via `ctx.commit` and emit the close batch to `onCommit` listeners.
|
|
2978
|
+
* The emission happens after the interaction core has settled (teardown and
|
|
2979
|
+
* `onSettled` already ran inside `ctx.commit`), so a listener that calls
|
|
2980
|
+
* `td.render()` sees these members no longer excluded from reconcile — the
|
|
2981
|
+
* fold-then-render pattern works from inside the handler. `undefined` covers
|
|
2982
|
+
* both an already-settled session and a `produce` throw (the promise
|
|
2983
|
+
* rejects); neither emits.
|
|
2984
|
+
*/
|
|
2985
|
+
function commitClose() {
|
|
2986
|
+
const snapshots = ctx.commit(rebuildRenderedAll);
|
|
2987
|
+
if (snapshots !== void 0) emitCommit(snapshots);
|
|
2988
|
+
return snapshots;
|
|
2989
|
+
}
|
|
2938
2990
|
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
|
|
2939
2991
|
if (ctx.settled) return;
|
|
2940
2992
|
const previousPointsByMember = clonePointsByMember();
|
|
@@ -2968,12 +3020,64 @@ function runGroupTransform(opts) {
|
|
|
2968
3020
|
dirty = true;
|
|
2969
3021
|
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
2970
3022
|
}
|
|
3023
|
+
/**
|
|
3024
|
+
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
3025
|
+
* geometry and option overrides, re-enable drag pan, and clear the drag. Used
|
|
3026
|
+
* by `setMembers` so a membership change never bakes a half-finished drag; the
|
|
3027
|
+
* caller is responsible for the follow-up repaint (`renderAllPreviews` /
|
|
3028
|
+
* `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
|
|
3029
|
+
* rather than rendering the outgoing member set only to redo it immediately.
|
|
3030
|
+
*/
|
|
3031
|
+
function cancelActiveBoxDrag() {
|
|
3032
|
+
if (!activeBoxDrag) return;
|
|
3033
|
+
const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3034
|
+
activeBoxDrag = null;
|
|
3035
|
+
driver.setDragPanEnabled(true);
|
|
3036
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3037
|
+
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
3038
|
+
dragInFlight = false;
|
|
3039
|
+
}
|
|
3040
|
+
function setMembers(next) {
|
|
3041
|
+
if (ctx.settled) return void 0;
|
|
3042
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3043
|
+
for (const m of next) {
|
|
3044
|
+
if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
|
|
3045
|
+
seen.add(m.id);
|
|
3046
|
+
}
|
|
3047
|
+
if (next.length === 0) return close();
|
|
3048
|
+
cancelActiveBoxDrag();
|
|
3049
|
+
const nextIds = new Set(next.map((m) => m.id));
|
|
3050
|
+
const currentById = new Map(members.map((m) => [m.measure.id, m]));
|
|
3051
|
+
const removedSnapshots = [];
|
|
3052
|
+
for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
3053
|
+
const nextMembers = [];
|
|
3054
|
+
for (const measure of next) {
|
|
3055
|
+
const existing = currentById.get(measure.id);
|
|
3056
|
+
if (existing) nextMembers.push(existing);
|
|
3057
|
+
else {
|
|
3058
|
+
const member = createMemberState(measure);
|
|
3059
|
+
liftMemberFromGraphics(member);
|
|
3060
|
+
nextMembers.push(member);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
members = nextMembers;
|
|
3064
|
+
renderAllPreviews();
|
|
3065
|
+
renderHandlesLayer();
|
|
3066
|
+
recomputeDirty();
|
|
3067
|
+
if (removedSnapshots.length > 0) emitCommit(removedSnapshots);
|
|
3068
|
+
return removedSnapshots;
|
|
3069
|
+
}
|
|
3070
|
+
function close() {
|
|
3071
|
+
return commitClose();
|
|
3072
|
+
}
|
|
2971
3073
|
const session = {
|
|
2972
|
-
measures
|
|
3074
|
+
get measures() {
|
|
3075
|
+
return members.map((m) => m.measure);
|
|
3076
|
+
},
|
|
2973
3077
|
controlPointsFor(measureId) {
|
|
2974
|
-
const
|
|
2975
|
-
if (
|
|
2976
|
-
return
|
|
3078
|
+
const member = members.find((m) => m.measure.id === measureId);
|
|
3079
|
+
if (!member) return void 0;
|
|
3080
|
+
return member.workingPoints.map((p) => [...p]);
|
|
2977
3081
|
},
|
|
2978
3082
|
get dirty() {
|
|
2979
3083
|
return dirty;
|
|
@@ -2984,10 +3088,8 @@ function runGroupTransform(opts) {
|
|
|
2984
3088
|
setSizeAnchor(next) {
|
|
2985
3089
|
setSizeAnchor(next);
|
|
2986
3090
|
},
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
ctx.commit(rebuildRenderedAll);
|
|
2990
|
-
},
|
|
3091
|
+
setMembers,
|
|
3092
|
+
close,
|
|
2991
3093
|
abort() {
|
|
2992
3094
|
if (ctx.settled) return;
|
|
2993
3095
|
ctx.fail(new TacticalDrawAbortError("session"));
|
|
@@ -2998,6 +3100,13 @@ function runGroupTransform(opts) {
|
|
|
2998
3100
|
return () => {
|
|
2999
3101
|
changeListeners.delete(listener);
|
|
3000
3102
|
};
|
|
3103
|
+
},
|
|
3104
|
+
onCommit(listener) {
|
|
3105
|
+
if (ctx.settled) return () => {};
|
|
3106
|
+
commitListeners.add(listener);
|
|
3107
|
+
return () => {
|
|
3108
|
+
commitListeners.delete(listener);
|
|
3109
|
+
};
|
|
3001
3110
|
}
|
|
3002
3111
|
};
|
|
3003
3112
|
let activeBoxDrag = null;
|
|
@@ -3100,12 +3209,13 @@ function runGroupTransform(opts) {
|
|
|
3100
3209
|
return;
|
|
3101
3210
|
}
|
|
3102
3211
|
if (localPreview || localHandles) return;
|
|
3103
|
-
|
|
3104
|
-
|
|
3212
|
+
const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
|
|
3213
|
+
if (localGraphics && !isMember) {
|
|
3214
|
+
commitClose();
|
|
3105
3215
|
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
3106
3216
|
return;
|
|
3107
3217
|
}
|
|
3108
|
-
|
|
3218
|
+
commitClose();
|
|
3109
3219
|
};
|
|
3110
3220
|
const core = runInteraction({
|
|
3111
3221
|
adapter,
|
|
@@ -3116,7 +3226,7 @@ function runGroupTransform(opts) {
|
|
|
3116
3226
|
attach(c) {
|
|
3117
3227
|
ctx = c;
|
|
3118
3228
|
driver = adapter.createEditPointerDriver();
|
|
3119
|
-
|
|
3229
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
3120
3230
|
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
3121
3231
|
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
3122
3232
|
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
@@ -3124,7 +3234,6 @@ function runGroupTransform(opts) {
|
|
|
3124
3234
|
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
3125
3235
|
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
3126
3236
|
adapter.on("click", handleClick);
|
|
3127
|
-
const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
|
|
3128
3237
|
c.onTeardown(() => {
|
|
3129
3238
|
adapter.off("click", handleClick);
|
|
3130
3239
|
unsubPickPreview();
|
|
@@ -3139,7 +3248,9 @@ function runGroupTransform(opts) {
|
|
|
3139
3248
|
dragInFlight = false;
|
|
3140
3249
|
}
|
|
3141
3250
|
driver.dispose();
|
|
3142
|
-
},
|
|
3251
|
+
}, () => {
|
|
3252
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
3253
|
+
});
|
|
3143
3254
|
renderAllPreviews();
|
|
3144
3255
|
renderHandlesLayer();
|
|
3145
3256
|
recomputeDirty();
|
|
@@ -3151,7 +3262,9 @@ function runGroupTransform(opts) {
|
|
|
3151
3262
|
get settled() {
|
|
3152
3263
|
return core.settled;
|
|
3153
3264
|
},
|
|
3154
|
-
measureIds
|
|
3265
|
+
get measureIds() {
|
|
3266
|
+
return members.map((m) => m.measure.id);
|
|
3267
|
+
},
|
|
3155
3268
|
abort(reason, cause) {
|
|
3156
3269
|
core.abort(reason, cause);
|
|
3157
3270
|
}
|
|
@@ -3297,10 +3410,21 @@ var TacticalDraw = class {
|
|
|
3297
3410
|
excludedInteractionIds() {
|
|
3298
3411
|
const ids = /* @__PURE__ */ new Set();
|
|
3299
3412
|
if (this.activeEdit) ids.add(this.activeEdit.measureId);
|
|
3300
|
-
|
|
3413
|
+
for (const id of this.transformMemberIds() ?? []) ids.add(id);
|
|
3301
3414
|
return ids;
|
|
3302
3415
|
}
|
|
3303
3416
|
/**
|
|
3417
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
3418
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
3419
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
3420
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
3421
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
3422
|
+
*/
|
|
3423
|
+
transformMemberIds() {
|
|
3424
|
+
if (this.activeTransform) return this.activeTransform.measureIds;
|
|
3425
|
+
return this.activeTransformSessionRef?.measures.map((m) => m.id);
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3304
3428
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
3305
3429
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
3306
3430
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -3339,7 +3463,7 @@ var TacticalDraw = class {
|
|
|
3339
3463
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
3340
3464
|
editedRemoved = externalEdited === void 0;
|
|
3341
3465
|
}
|
|
3342
|
-
const transformIds = this.
|
|
3466
|
+
const transformIds = this.transformMemberIds();
|
|
3343
3467
|
const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
|
|
3344
3468
|
const excluded = this.excludedInteractionIds();
|
|
3345
3469
|
const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
|
|
@@ -3355,10 +3479,14 @@ var TacticalDraw = class {
|
|
|
3355
3479
|
editHandle.abort("removed");
|
|
3356
3480
|
});
|
|
3357
3481
|
}
|
|
3358
|
-
if (transformMemberRemoved
|
|
3359
|
-
const
|
|
3482
|
+
if (transformMemberRemoved) {
|
|
3483
|
+
const sessionRef = this.activeTransformSessionRef;
|
|
3360
3484
|
queueMicrotask(() => {
|
|
3361
|
-
|
|
3485
|
+
const handle = this.activeTransform;
|
|
3486
|
+
if (!handle || handle.settled) return;
|
|
3487
|
+
if (this.activeTransformSessionRef !== sessionRef) return;
|
|
3488
|
+
const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
|
|
3489
|
+
if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
|
|
3362
3490
|
});
|
|
3363
3491
|
}
|
|
3364
3492
|
}
|
|
@@ -3564,13 +3692,22 @@ var TacticalDraw = class {
|
|
|
3564
3692
|
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
3565
3693
|
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
3566
3694
|
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
3567
|
-
* resolves with
|
|
3568
|
-
* `
|
|
3569
|
-
*
|
|
3695
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
3696
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
3697
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
3698
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
3699
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
3700
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
3701
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
3570
3702
|
*
|
|
3571
3703
|
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
3572
3704
|
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
3573
3705
|
* any preemption) for an empty array or duplicate ids.
|
|
3706
|
+
*
|
|
3707
|
+
* For a selection that changes over time (shift-click toggle, marquee
|
|
3708
|
+
* replace), prefer [[syncTransformMembers]] over hand-rolling the
|
|
3709
|
+
* live-session-vs-fresh-`editMany` branch, and subscribe to
|
|
3710
|
+
* `TransformSession.onCommit` as the single fold path for exit snapshots.
|
|
3574
3711
|
*/
|
|
3575
3712
|
editMany(measures, options) {
|
|
3576
3713
|
this.assertAlive();
|
|
@@ -3582,7 +3719,7 @@ var TacticalDraw = class {
|
|
|
3582
3719
|
}
|
|
3583
3720
|
const preempt = this.guardAndPreempt();
|
|
3584
3721
|
if (preempt) return preempt;
|
|
3585
|
-
const
|
|
3722
|
+
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
3586
3723
|
const restoreOriginalToGraphics = () => {
|
|
3587
3724
|
this.renderMeasuresToGraphics(this.lastRenderedMeasures);
|
|
3588
3725
|
};
|
|
@@ -3608,7 +3745,7 @@ var TacticalDraw = class {
|
|
|
3608
3745
|
handleIdPrefix,
|
|
3609
3746
|
sizeAnchor: options?.sizeAnchor,
|
|
3610
3747
|
restoreOriginalToGraphics,
|
|
3611
|
-
|
|
3748
|
+
isOnGraphicsLayer,
|
|
3612
3749
|
onSettled,
|
|
3613
3750
|
onClickAwayGraphicsPick
|
|
3614
3751
|
}), (handle) => {
|
|
@@ -3619,6 +3756,42 @@ var TacticalDraw = class {
|
|
|
3619
3756
|
}, extraSettle);
|
|
3620
3757
|
}
|
|
3621
3758
|
/**
|
|
3759
|
+
* One-call selection-sync: mutate the live group transform's member set in
|
|
3760
|
+
* place, or start a fresh `editMany` when no transform is live (#191). This
|
|
3761
|
+
* owns the "live session → `setMembers`, else → `editMany`" branch that every
|
|
3762
|
+
* host otherwise hand-rolls against `activeTransformSession`.
|
|
3763
|
+
*
|
|
3764
|
+
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
3765
|
+
* is cleared inside the settle path, before control returns to host code — so
|
|
3766
|
+
* unlike a host's own `activeTransformSession ? setMembers : editMany` check,
|
|
3767
|
+
* this can never observe a stale ref pointing at an already-settled session.
|
|
3768
|
+
* The settled-race fall-through hosts had to hand-roll for that gap is owned
|
|
3769
|
+
* here instead: a `setMembers` call that (implausibly) still returns
|
|
3770
|
+
* `undefined` falls through to a fresh `editMany` rather than silently
|
|
3771
|
+
* dropping the selection.
|
|
3772
|
+
*
|
|
3773
|
+
* `measures: []` closes any live session (committing every member) and is a
|
|
3774
|
+
* no-op while idle. With a live session, `options` is ignored — session-scoped
|
|
3775
|
+
* state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
|
|
3776
|
+
* `setMembers`'s contract; `options` applies only when this call starts a
|
|
3777
|
+
* fresh `editMany`. Exit snapshots are delivered via the session's
|
|
3778
|
+
* `onCommit` — subscribe once in `options.onSession`. The underlying
|
|
3779
|
+
* `editMany` promise is owned by the facade; its abort rejections are
|
|
3780
|
+
* swallowed here (non-abort errors surface as unhandled rejections). Throws
|
|
3781
|
+
* `TypeError` synchronously on duplicate ids, from either path. Starting a
|
|
3782
|
+
* fresh transform preempts any other in-flight draw/edit exactly like
|
|
3783
|
+
* `editMany`.
|
|
3784
|
+
*/
|
|
3785
|
+
syncTransformMembers(measures, options) {
|
|
3786
|
+
this.assertAlive();
|
|
3787
|
+
const session = this.activeTransformSessionRef;
|
|
3788
|
+
if (session) {
|
|
3789
|
+
if (session.setMembers(measures) !== void 0) return;
|
|
3790
|
+
}
|
|
3791
|
+
if (measures.length === 0) return;
|
|
3792
|
+
this.editMany([...measures], options).catch(ignoreAbort);
|
|
3793
|
+
}
|
|
3794
|
+
/**
|
|
3622
3795
|
* Subscribe to picks on committed control measures.
|
|
3623
3796
|
*
|
|
3624
3797
|
* The graphics layer is pre-bound, and `event.id` is the originating control
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orbat-mapper/tactical-draw",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.9",
|
|
4
4
|
"description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Orbat Mapper",
|