@orbat-mapper/tactical-draw 0.2.0-alpha.7 → 0.2.0-alpha.8
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 +87 -18
- package/dist/index.mjs +198 -104
- 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,50 @@ 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
|
-
*
|
|
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. This is the *only* delivery of a
|
|
652
|
+
* removed member's exit state: the outer `editMany` promise resolves with the
|
|
653
|
+
* snapshots of the members in the set **at close time only**. Removed members
|
|
654
|
+
* are NOT written back to the graphics layer by the controller — the host
|
|
655
|
+
* folds the returned snapshots into its document and calls `td.render()`, the
|
|
656
|
+
* same contract as [[close]]. Returns `undefined` (a silent no-op) after the
|
|
657
|
+
* session has settled.
|
|
658
|
+
*
|
|
659
|
+
* `setMembers([])` on a live session delegates to [[close]]: it commits every
|
|
660
|
+
* current member, resolves the promise with their snapshots, and returns that
|
|
661
|
+
* same array. Duplicate ids in `measures` throw `TypeError` synchronously,
|
|
662
|
+
* without mutating session state.
|
|
663
|
+
*/
|
|
664
|
+
setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
|
|
665
|
+
/**
|
|
666
|
+
* Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
|
|
667
|
+
* set at close time) and resolves the outer `editMany` promise with the same
|
|
668
|
+
* value (the same reference — no double-bake of ground-anchored sizes). A
|
|
669
|
+
* member removed mid-session via [[setMembers]] is not included here; its exit
|
|
670
|
+
* snapshot was returned from that `setMembers` call. Returns `undefined` after
|
|
671
|
+
* the session has settled (idempotent no-op).
|
|
620
672
|
*/
|
|
621
|
-
close():
|
|
673
|
+
close(): ControlMeasureSnapshot[] | undefined;
|
|
622
674
|
/** Reject the outer `editMany` promise with reason `"session"`. */
|
|
623
675
|
abort(): void;
|
|
624
676
|
/**
|
|
@@ -638,13 +690,17 @@ interface TransformSession {
|
|
|
638
690
|
/**
|
|
639
691
|
* Delivered to `TransformSession.onChange` listeners once per completed group
|
|
640
692
|
* gesture. Both `measures` and `previous` are index-aligned with
|
|
641
|
-
* `session.measures
|
|
642
|
-
*
|
|
693
|
+
* `session.measures` *as of this emission*. Key by `measure.id` rather than
|
|
694
|
+
* relying on index alignment across emissions. A `setMembers` call from within
|
|
695
|
+
* a listener takes effect immediately and supersedes this emission's
|
|
696
|
+
* `reject()` (the rollback baseline no longer matches the member set);
|
|
697
|
+
* `close()` and `abort()` still apply, against the new membership. See
|
|
698
|
+
* `EditChangeEvent` for the identical emission contract this mirrors.
|
|
643
699
|
*/
|
|
644
700
|
interface TransformChangeEvent {
|
|
645
|
-
/** Working state after the completed gesture, index-aligned with `session.measures
|
|
701
|
+
/** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
|
|
646
702
|
readonly measures: ControlMeasureSnapshot[];
|
|
647
|
-
/** State immediately before this gesture, index-aligned with `session.measures
|
|
703
|
+
/** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
|
|
648
704
|
readonly previous: ControlMeasureSnapshot[];
|
|
649
705
|
/** The session the event belongs to. */
|
|
650
706
|
readonly session: TransformSession;
|
|
@@ -652,7 +708,8 @@ interface TransformChangeEvent {
|
|
|
652
708
|
* Roll every member's working preview back to `previous` after all
|
|
653
709
|
* listeners have run. Multiple calls collapse to a single rollback.
|
|
654
710
|
* Overridden by `close()` or `abort()` if either is also called during this
|
|
655
|
-
* emission.
|
|
711
|
+
* emission, and dropped if a listener called `session.setMembers` (the
|
|
712
|
+
* rollback baseline no longer matches the member set).
|
|
656
713
|
*/
|
|
657
714
|
reject(): void;
|
|
658
715
|
/** Shortcut for `event.session.close()`, deferred until the emission ends. */
|
|
@@ -945,6 +1002,14 @@ declare class TacticalDraw {
|
|
|
945
1002
|
* working copy. Empty while idle.
|
|
946
1003
|
*/
|
|
947
1004
|
private excludedInteractionIds;
|
|
1005
|
+
/**
|
|
1006
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
1007
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
1008
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
1009
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
1010
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
1011
|
+
*/
|
|
1012
|
+
private transformMemberIds;
|
|
948
1013
|
/**
|
|
949
1014
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
950
1015
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
@@ -1028,9 +1093,13 @@ declare class TacticalDraw {
|
|
|
1028
1093
|
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
1029
1094
|
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
1030
1095
|
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
1031
|
-
* resolves with
|
|
1032
|
-
* `
|
|
1033
|
-
*
|
|
1096
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
1097
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
1098
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
1099
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
1100
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
1101
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
1102
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
1034
1103
|
*
|
|
1035
1104
|
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
1036
1105
|
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
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
|
/**
|
|
@@ -2723,22 +2741,22 @@ function runGroupTransform(opts) {
|
|
|
2723
2741
|
let boxAngle = 0;
|
|
2724
2742
|
/** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
|
|
2725
2743
|
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
2744
|
let ctx;
|
|
2731
2745
|
let driver;
|
|
2732
2746
|
const dragProjection = {
|
|
2733
2747
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2734
2748
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2735
2749
|
};
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2750
|
+
/**
|
|
2751
|
+
* Lift `member` off the graphics layer when the host reports it currently
|
|
2752
|
+
* there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
|
|
2753
|
+
* Used for both initial and mid-session-added members.
|
|
2754
|
+
*/
|
|
2755
|
+
function liftMemberFromGraphics(member) {
|
|
2756
|
+
if (!isOnGraphicsLayer(member.measure.id)) return;
|
|
2757
|
+
const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
2758
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
2759
|
+
anyLiftedFromGraphics = true;
|
|
2742
2760
|
}
|
|
2743
2761
|
function pixelsFor(features) {
|
|
2744
2762
|
const positions = [];
|
|
@@ -2755,7 +2773,7 @@ function runGroupTransform(opts) {
|
|
|
2755
2773
|
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
2756
2774
|
}
|
|
2757
2775
|
/**
|
|
2758
|
-
* Re-fit `groupBox` and every `
|
|
2776
|
+
* Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
|
|
2759
2777
|
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
2760
2778
|
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
2761
2779
|
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
@@ -2766,18 +2784,18 @@ function runGroupTransform(opts) {
|
|
|
2766
2784
|
const angle = currentAngle();
|
|
2767
2785
|
const padding = interactionStyle?.boxPadding ?? 0;
|
|
2768
2786
|
const allPixels = [];
|
|
2769
|
-
|
|
2770
|
-
const pixels = pixelsFor(
|
|
2787
|
+
for (const member of members) {
|
|
2788
|
+
const pixels = pixelsFor(member.lastPreviewFeatures);
|
|
2771
2789
|
allPixels.push(...pixels);
|
|
2772
|
-
|
|
2773
|
-
}
|
|
2790
|
+
member.box = fitTransformBox(pixels, angle, padding);
|
|
2791
|
+
}
|
|
2774
2792
|
if (activeBoxDrag) return;
|
|
2775
2793
|
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
2776
2794
|
}
|
|
2777
2795
|
function renderAllPreviews() {
|
|
2778
2796
|
if (ctx.settled) return;
|
|
2779
2797
|
const allFeatures = [];
|
|
2780
|
-
|
|
2798
|
+
members.forEach((member, i) => {
|
|
2781
2799
|
const working = workingMeasureFor(i);
|
|
2782
2800
|
let features;
|
|
2783
2801
|
try {
|
|
@@ -2785,17 +2803,17 @@ function runGroupTransform(opts) {
|
|
|
2785
2803
|
} catch {
|
|
2786
2804
|
features = [];
|
|
2787
2805
|
}
|
|
2806
|
+
member.lastPreviewFeatures = features;
|
|
2788
2807
|
allFeatures.push(...features);
|
|
2789
|
-
return features;
|
|
2790
2808
|
});
|
|
2791
2809
|
ctx.writeLayer(previewLayer, allFeatures);
|
|
2792
2810
|
refitBoxes();
|
|
2793
2811
|
}
|
|
2794
2812
|
function rescaleOnViewChange() {
|
|
2795
2813
|
if (ctx.settled) return;
|
|
2796
|
-
if (
|
|
2797
|
-
kind: m.kind,
|
|
2798
|
-
options: m.options
|
|
2814
|
+
if (members.some((m) => measureUsesAdapterResolution({
|
|
2815
|
+
kind: m.measure.kind,
|
|
2816
|
+
options: m.measure.options
|
|
2799
2817
|
}))) renderAllPreviews();
|
|
2800
2818
|
else refitBoxes();
|
|
2801
2819
|
renderHandlesLayer();
|
|
@@ -2821,59 +2839,62 @@ function runGroupTransform(opts) {
|
|
|
2821
2839
|
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
|
|
2822
2840
|
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2823
2841
|
}
|
|
2824
|
-
|
|
2825
|
-
const memberBox =
|
|
2826
|
-
if (!memberBox)
|
|
2827
|
-
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
|
|
2828
|
-
}
|
|
2842
|
+
for (const member of members) {
|
|
2843
|
+
const memberBox = member.box;
|
|
2844
|
+
if (!memberBox) continue;
|
|
2845
|
+
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
|
|
2846
|
+
}
|
|
2829
2847
|
ctx.writeLayer(handlesLayer, features);
|
|
2830
2848
|
}
|
|
2831
2849
|
/**
|
|
2832
2850
|
* Builds every member's committed snapshot against a given override array
|
|
2833
|
-
* rather than the live
|
|
2834
|
-
*
|
|
2835
|
-
*
|
|
2851
|
+
* rather than the live per-member override — used to render a gesture's
|
|
2852
|
+
* pre-freeze "previous" snapshot when a rotate has already mutated the live
|
|
2853
|
+
* overrides by the time the commit runs. Index-aligned with `members`, which
|
|
2854
|
+
* is sound because membership cannot change within a gesture emission.
|
|
2836
2855
|
*/
|
|
2837
2856
|
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
|
-
});
|
|
2857
|
+
return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
|
|
2846
2858
|
}
|
|
2847
2859
|
function rebuildRenderedAll() {
|
|
2848
|
-
return
|
|
2860
|
+
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
2849
2861
|
}
|
|
2850
2862
|
function recomputeDirty() {
|
|
2851
|
-
dirty = nonGeometryDirty ||
|
|
2863
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
|
|
2852
2864
|
}
|
|
2853
|
-
/** Split `points` (the concatenation of every member's control points) back per member
|
|
2865
|
+
/** Split `points` (the concatenation of every member's control points) back per member. */
|
|
2854
2866
|
function applyConcatenatedPoints(points) {
|
|
2855
2867
|
const rounded = roundControlPoints(points);
|
|
2856
2868
|
let offset = 0;
|
|
2857
|
-
for (
|
|
2858
|
-
const c =
|
|
2869
|
+
for (const member of members) {
|
|
2870
|
+
const c = member.workingPoints.length;
|
|
2859
2871
|
const slice = rounded.slice(offset, offset + c);
|
|
2860
|
-
|
|
2872
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...slice);
|
|
2861
2873
|
offset += c;
|
|
2862
2874
|
}
|
|
2863
2875
|
}
|
|
2864
2876
|
function concatWorkingPoints() {
|
|
2865
2877
|
const out = [];
|
|
2866
|
-
for (const
|
|
2878
|
+
for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
|
|
2867
2879
|
return out;
|
|
2868
2880
|
}
|
|
2869
2881
|
function clonePointsByMember() {
|
|
2870
|
-
return
|
|
2882
|
+
return members.map((m) => m.workingPoints.map((p) => [...p]));
|
|
2871
2883
|
}
|
|
2872
2884
|
function restorePointsByMember(snapshot) {
|
|
2873
|
-
|
|
2885
|
+
members.forEach((member, i) => {
|
|
2886
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
2887
|
+
});
|
|
2874
2888
|
}
|
|
2875
2889
|
function cloneOptionsOverrideByMember() {
|
|
2876
|
-
return
|
|
2890
|
+
return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
|
|
2891
|
+
}
|
|
2892
|
+
/** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
|
|
2893
|
+
function applyOptionsOverrides(overrides) {
|
|
2894
|
+
members.forEach((member, i) => {
|
|
2895
|
+
const o = overrides[i];
|
|
2896
|
+
member.optionsOverride = o ? { ...o } : null;
|
|
2897
|
+
});
|
|
2877
2898
|
}
|
|
2878
2899
|
/**
|
|
2879
2900
|
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
@@ -2882,10 +2903,10 @@ function runGroupTransform(opts) {
|
|
|
2882
2903
|
* per-member since the group has no single working-options set.
|
|
2883
2904
|
*/
|
|
2884
2905
|
function freezeOrientationForRotate() {
|
|
2885
|
-
|
|
2886
|
-
const patch = freezeOrientationOptions(measure.kind,
|
|
2887
|
-
if (patch)
|
|
2888
|
-
...
|
|
2906
|
+
members.forEach((member, i) => {
|
|
2907
|
+
const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
2908
|
+
if (patch) member.optionsOverride = {
|
|
2909
|
+
...member.optionsOverride ?? {},
|
|
2889
2910
|
...patch
|
|
2890
2911
|
};
|
|
2891
2912
|
});
|
|
@@ -2915,6 +2936,7 @@ function runGroupTransform(opts) {
|
|
|
2915
2936
|
abortRequested = true;
|
|
2916
2937
|
}
|
|
2917
2938
|
};
|
|
2939
|
+
const membersAtEmission = members;
|
|
2918
2940
|
const errors = [];
|
|
2919
2941
|
for (const listener of [...changeListeners]) try {
|
|
2920
2942
|
listener(event);
|
|
@@ -2923,11 +2945,11 @@ function runGroupTransform(opts) {
|
|
|
2923
2945
|
}
|
|
2924
2946
|
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
2925
2947
|
else if (closeRequested) ctx.commit(rebuildRenderedAll);
|
|
2926
|
-
else if (rejectRequested && !ctx.settled) {
|
|
2948
|
+
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
2927
2949
|
restorePointsByMember(previousPointsByMember);
|
|
2928
2950
|
sizeAnchor = previousSizeAnchor;
|
|
2929
2951
|
nonGeometryDirty = previousNonGeometryDirty;
|
|
2930
|
-
|
|
2952
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
2931
2953
|
renderAllPreviews();
|
|
2932
2954
|
renderHandlesLayer();
|
|
2933
2955
|
recomputeDirty();
|
|
@@ -2968,12 +2990,63 @@ function runGroupTransform(opts) {
|
|
|
2968
2990
|
dirty = true;
|
|
2969
2991
|
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
2970
2992
|
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
2995
|
+
* geometry and option overrides, re-enable drag pan, and clear the drag. Used
|
|
2996
|
+
* by `setMembers` so a membership change never bakes a half-finished drag; the
|
|
2997
|
+
* caller is responsible for the follow-up repaint (`renderAllPreviews` /
|
|
2998
|
+
* `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
|
|
2999
|
+
* rather than rendering the outgoing member set only to redo it immediately.
|
|
3000
|
+
*/
|
|
3001
|
+
function cancelActiveBoxDrag() {
|
|
3002
|
+
if (!activeBoxDrag) return;
|
|
3003
|
+
const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3004
|
+
activeBoxDrag = null;
|
|
3005
|
+
driver.setDragPanEnabled(true);
|
|
3006
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3007
|
+
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
3008
|
+
dragInFlight = false;
|
|
3009
|
+
}
|
|
3010
|
+
function setMembers(next) {
|
|
3011
|
+
if (ctx.settled) return void 0;
|
|
3012
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3013
|
+
for (const m of next) {
|
|
3014
|
+
if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
|
|
3015
|
+
seen.add(m.id);
|
|
3016
|
+
}
|
|
3017
|
+
if (next.length === 0) return close();
|
|
3018
|
+
cancelActiveBoxDrag();
|
|
3019
|
+
const nextIds = new Set(next.map((m) => m.id));
|
|
3020
|
+
const currentById = new Map(members.map((m) => [m.measure.id, m]));
|
|
3021
|
+
const removedSnapshots = [];
|
|
3022
|
+
for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
3023
|
+
const nextMembers = [];
|
|
3024
|
+
for (const measure of next) {
|
|
3025
|
+
const existing = currentById.get(measure.id);
|
|
3026
|
+
if (existing) nextMembers.push(existing);
|
|
3027
|
+
else {
|
|
3028
|
+
const member = createMemberState(measure);
|
|
3029
|
+
liftMemberFromGraphics(member);
|
|
3030
|
+
nextMembers.push(member);
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
members = nextMembers;
|
|
3034
|
+
renderAllPreviews();
|
|
3035
|
+
renderHandlesLayer();
|
|
3036
|
+
recomputeDirty();
|
|
3037
|
+
return removedSnapshots;
|
|
3038
|
+
}
|
|
3039
|
+
function close() {
|
|
3040
|
+
return ctx.commit(rebuildRenderedAll);
|
|
3041
|
+
}
|
|
2971
3042
|
const session = {
|
|
2972
|
-
measures
|
|
3043
|
+
get measures() {
|
|
3044
|
+
return members.map((m) => m.measure);
|
|
3045
|
+
},
|
|
2973
3046
|
controlPointsFor(measureId) {
|
|
2974
|
-
const
|
|
2975
|
-
if (
|
|
2976
|
-
return
|
|
3047
|
+
const member = members.find((m) => m.measure.id === measureId);
|
|
3048
|
+
if (!member) return void 0;
|
|
3049
|
+
return member.workingPoints.map((p) => [...p]);
|
|
2977
3050
|
},
|
|
2978
3051
|
get dirty() {
|
|
2979
3052
|
return dirty;
|
|
@@ -2984,10 +3057,8 @@ function runGroupTransform(opts) {
|
|
|
2984
3057
|
setSizeAnchor(next) {
|
|
2985
3058
|
setSizeAnchor(next);
|
|
2986
3059
|
},
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
ctx.commit(rebuildRenderedAll);
|
|
2990
|
-
},
|
|
3060
|
+
setMembers,
|
|
3061
|
+
close,
|
|
2991
3062
|
abort() {
|
|
2992
3063
|
if (ctx.settled) return;
|
|
2993
3064
|
ctx.fail(new TacticalDrawAbortError("session"));
|
|
@@ -3100,7 +3171,8 @@ function runGroupTransform(opts) {
|
|
|
3100
3171
|
return;
|
|
3101
3172
|
}
|
|
3102
3173
|
if (localPreview || localHandles) return;
|
|
3103
|
-
|
|
3174
|
+
const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
|
|
3175
|
+
if (localGraphics && !isMember) {
|
|
3104
3176
|
ctx.commit(rebuildRenderedAll);
|
|
3105
3177
|
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
3106
3178
|
return;
|
|
@@ -3116,7 +3188,7 @@ function runGroupTransform(opts) {
|
|
|
3116
3188
|
attach(c) {
|
|
3117
3189
|
ctx = c;
|
|
3118
3190
|
driver = adapter.createEditPointerDriver();
|
|
3119
|
-
|
|
3191
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
3120
3192
|
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
3121
3193
|
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
3122
3194
|
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
@@ -3124,7 +3196,6 @@ function runGroupTransform(opts) {
|
|
|
3124
3196
|
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
3125
3197
|
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
3126
3198
|
adapter.on("click", handleClick);
|
|
3127
|
-
const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
|
|
3128
3199
|
c.onTeardown(() => {
|
|
3129
3200
|
adapter.off("click", handleClick);
|
|
3130
3201
|
unsubPickPreview();
|
|
@@ -3139,7 +3210,9 @@ function runGroupTransform(opts) {
|
|
|
3139
3210
|
dragInFlight = false;
|
|
3140
3211
|
}
|
|
3141
3212
|
driver.dispose();
|
|
3142
|
-
},
|
|
3213
|
+
}, () => {
|
|
3214
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
3215
|
+
});
|
|
3143
3216
|
renderAllPreviews();
|
|
3144
3217
|
renderHandlesLayer();
|
|
3145
3218
|
recomputeDirty();
|
|
@@ -3151,7 +3224,9 @@ function runGroupTransform(opts) {
|
|
|
3151
3224
|
get settled() {
|
|
3152
3225
|
return core.settled;
|
|
3153
3226
|
},
|
|
3154
|
-
measureIds
|
|
3227
|
+
get measureIds() {
|
|
3228
|
+
return members.map((m) => m.measure.id);
|
|
3229
|
+
},
|
|
3155
3230
|
abort(reason, cause) {
|
|
3156
3231
|
core.abort(reason, cause);
|
|
3157
3232
|
}
|
|
@@ -3297,10 +3372,21 @@ var TacticalDraw = class {
|
|
|
3297
3372
|
excludedInteractionIds() {
|
|
3298
3373
|
const ids = /* @__PURE__ */ new Set();
|
|
3299
3374
|
if (this.activeEdit) ids.add(this.activeEdit.measureId);
|
|
3300
|
-
|
|
3375
|
+
for (const id of this.transformMemberIds() ?? []) ids.add(id);
|
|
3301
3376
|
return ids;
|
|
3302
3377
|
}
|
|
3303
3378
|
/**
|
|
3379
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
3380
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
3381
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
3382
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
3383
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
3384
|
+
*/
|
|
3385
|
+
transformMemberIds() {
|
|
3386
|
+
if (this.activeTransform) return this.activeTransform.measureIds;
|
|
3387
|
+
return this.activeTransformSessionRef?.measures.map((m) => m.id);
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3304
3390
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
3305
3391
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
3306
3392
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -3339,7 +3425,7 @@ var TacticalDraw = class {
|
|
|
3339
3425
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
3340
3426
|
editedRemoved = externalEdited === void 0;
|
|
3341
3427
|
}
|
|
3342
|
-
const transformIds = this.
|
|
3428
|
+
const transformIds = this.transformMemberIds();
|
|
3343
3429
|
const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
|
|
3344
3430
|
const excluded = this.excludedInteractionIds();
|
|
3345
3431
|
const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
|
|
@@ -3355,10 +3441,14 @@ var TacticalDraw = class {
|
|
|
3355
3441
|
editHandle.abort("removed");
|
|
3356
3442
|
});
|
|
3357
3443
|
}
|
|
3358
|
-
if (transformMemberRemoved
|
|
3359
|
-
const
|
|
3444
|
+
if (transformMemberRemoved) {
|
|
3445
|
+
const sessionRef = this.activeTransformSessionRef;
|
|
3360
3446
|
queueMicrotask(() => {
|
|
3361
|
-
|
|
3447
|
+
const handle = this.activeTransform;
|
|
3448
|
+
if (!handle || handle.settled) return;
|
|
3449
|
+
if (this.activeTransformSessionRef !== sessionRef) return;
|
|
3450
|
+
const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
|
|
3451
|
+
if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
|
|
3362
3452
|
});
|
|
3363
3453
|
}
|
|
3364
3454
|
}
|
|
@@ -3564,9 +3654,13 @@ var TacticalDraw = class {
|
|
|
3564
3654
|
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
3565
3655
|
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
3566
3656
|
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
3567
|
-
* resolves with
|
|
3568
|
-
* `
|
|
3569
|
-
*
|
|
3657
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
3658
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
3659
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
3660
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
3661
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
3662
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
3663
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
3570
3664
|
*
|
|
3571
3665
|
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
3572
3666
|
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
@@ -3582,7 +3676,7 @@ var TacticalDraw = class {
|
|
|
3582
3676
|
}
|
|
3583
3677
|
const preempt = this.guardAndPreempt();
|
|
3584
3678
|
if (preempt) return preempt;
|
|
3585
|
-
const
|
|
3679
|
+
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
3586
3680
|
const restoreOriginalToGraphics = () => {
|
|
3587
3681
|
this.renderMeasuresToGraphics(this.lastRenderedMeasures);
|
|
3588
3682
|
};
|
|
@@ -3608,7 +3702,7 @@ var TacticalDraw = class {
|
|
|
3608
3702
|
handleIdPrefix,
|
|
3609
3703
|
sizeAnchor: options?.sizeAnchor,
|
|
3610
3704
|
restoreOriginalToGraphics,
|
|
3611
|
-
|
|
3705
|
+
isOnGraphicsLayer,
|
|
3612
3706
|
onSettled,
|
|
3613
3707
|
onClickAwayGraphicsPick
|
|
3614
3708
|
}), (handle) => {
|
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.8",
|
|
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",
|