@orbat-mapper/tactical-draw 0.2.0-alpha.1 → 0.2.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +552 -34
- package/dist/index.mjs +1738 -354
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cloneControlMeasure, controlMeasureIdFromFeature, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
|
|
1
|
+
import { cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
|
|
2
2
|
//#region src/base-map-adapter.ts
|
|
3
3
|
/**
|
|
4
4
|
* Abstract base class shared by all `MapAdapter` implementations.
|
|
@@ -519,17 +519,21 @@ const PIXEL_SIZE_OPTIONS = {
|
|
|
519
519
|
pixels: "echelonSizePixels",
|
|
520
520
|
meters: "echelonSize"
|
|
521
521
|
},
|
|
522
|
+
"strong-point": {
|
|
523
|
+
pixels: "echelonSizePixels",
|
|
524
|
+
meters: "echelonSize"
|
|
525
|
+
},
|
|
522
526
|
flot: {
|
|
523
527
|
pixels: "radiusPixels",
|
|
524
528
|
meters: "radius"
|
|
525
529
|
},
|
|
526
530
|
"fortified-line": {
|
|
527
|
-
pixels: "
|
|
528
|
-
meters: "
|
|
531
|
+
pixels: "toothSizePixels",
|
|
532
|
+
meters: "toothSize"
|
|
529
533
|
},
|
|
530
534
|
"fortified-area": {
|
|
531
|
-
pixels: "
|
|
532
|
-
meters: "
|
|
535
|
+
pixels: "toothSizePixels",
|
|
536
|
+
meters: "toothSize"
|
|
533
537
|
},
|
|
534
538
|
"antitank-ditch-under-construction": {
|
|
535
539
|
pixels: "toothHeightPixels",
|
|
@@ -596,7 +600,7 @@ function bakePixelSizeToGround(measure, adapter) {
|
|
|
596
600
|
/**
|
|
597
601
|
* Stamp the adapter's current `metersPerPixel` onto a measure that sizes itself
|
|
598
602
|
* in pixels, so the generator resolves pixel options (`radiusPixels`,
|
|
599
|
-
* `
|
|
603
|
+
* `toothSizePixels`, `toothHeightPixels`) against the live map zoom. Returns the
|
|
600
604
|
* measure unchanged when the adapter has no usable resolution or the
|
|
601
605
|
* kind/options don't opt into pixel sizing.
|
|
602
606
|
*/
|
|
@@ -695,7 +699,7 @@ function runInteraction(spec) {
|
|
|
695
699
|
clearLayers();
|
|
696
700
|
}
|
|
697
701
|
function commit(produce) {
|
|
698
|
-
if (settled) return;
|
|
702
|
+
if (settled) return void 0;
|
|
699
703
|
let value;
|
|
700
704
|
try {
|
|
701
705
|
value = produce();
|
|
@@ -703,13 +707,20 @@ function runInteraction(spec) {
|
|
|
703
707
|
settled = true;
|
|
704
708
|
teardown();
|
|
705
709
|
rejectFn(err);
|
|
706
|
-
if (onSettled) onSettled(
|
|
710
|
+
if (onSettled) onSettled({
|
|
711
|
+
status: "rejected",
|
|
712
|
+
error: err
|
|
713
|
+
});
|
|
707
714
|
return;
|
|
708
715
|
}
|
|
709
716
|
settled = true;
|
|
710
717
|
teardown();
|
|
711
718
|
resolveFn(value);
|
|
712
|
-
if (onSettled) onSettled(
|
|
719
|
+
if (onSettled) onSettled({
|
|
720
|
+
status: "committed",
|
|
721
|
+
value
|
|
722
|
+
});
|
|
723
|
+
return value;
|
|
713
724
|
}
|
|
714
725
|
function fail(error) {
|
|
715
726
|
if (settled) return;
|
|
@@ -719,7 +730,10 @@ function runInteraction(spec) {
|
|
|
719
730
|
controllerOnAbort();
|
|
720
731
|
} catch {}
|
|
721
732
|
rejectFn(error);
|
|
722
|
-
if (onSettled) onSettled(
|
|
733
|
+
if (onSettled) onSettled({
|
|
734
|
+
status: "rejected",
|
|
735
|
+
error
|
|
736
|
+
});
|
|
723
737
|
}
|
|
724
738
|
const ctx = {
|
|
725
739
|
get settled() {
|
|
@@ -769,6 +783,82 @@ function roundControlPoints(points) {
|
|
|
769
783
|
return points.map(roundControlPoint);
|
|
770
784
|
}
|
|
771
785
|
//#endregion
|
|
786
|
+
//#region src/tactical-draw/guide.ts
|
|
787
|
+
/**
|
|
788
|
+
* Stable preview-layer ids and emission rules for the rubber-band guide
|
|
789
|
+
* (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
|
|
790
|
+
* single styling rule across both interactions.
|
|
791
|
+
*/
|
|
792
|
+
const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
|
|
793
|
+
const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
|
|
794
|
+
const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
|
|
795
|
+
/**
|
|
796
|
+
* A kind is fixed-length when its draw rule pins the slot count
|
|
797
|
+
* (`canonicalPointCount`) or its metadata declares `min === max`. The
|
|
798
|
+
* generator preview is already authoritative for these, so the rubber-band
|
|
799
|
+
* guide adds no information.
|
|
800
|
+
*/
|
|
801
|
+
function isFixedLength(metadata) {
|
|
802
|
+
if (metadata.rule?.canonicalPointCount !== void 0) return true;
|
|
803
|
+
const min = metadata.minCoordinates;
|
|
804
|
+
const max = metadata.maxCoordinates;
|
|
805
|
+
return typeof min === "number" && typeof max === "number" && min === max;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Guide is mechanically suppressed for kinds where it carries no information:
|
|
809
|
+
* point kinds and fixed-length kinds. Independent of the host's `guide`
|
|
810
|
+
* opt-out.
|
|
811
|
+
*/
|
|
812
|
+
function shouldSuppressGuide(metadata) {
|
|
813
|
+
if (metadata.geometry === "point") return true;
|
|
814
|
+
if (metadata.rule?.showGuide === true) return false;
|
|
815
|
+
return isFixedLength(metadata);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Build the guide LineString features for a point list.
|
|
819
|
+
* - Open polyline through `points` once `points.length >= 2`.
|
|
820
|
+
* - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
|
|
821
|
+
*
|
|
822
|
+
* Callers are responsible for the opt-out (`guide: false`) and the suppression
|
|
823
|
+
* check via [[shouldSuppressGuide]] — this function only emits geometry.
|
|
824
|
+
*/
|
|
825
|
+
function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
|
|
826
|
+
const features = [];
|
|
827
|
+
const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
|
|
828
|
+
if (spine.length >= 2) {
|
|
829
|
+
const open = {
|
|
830
|
+
type: "Feature",
|
|
831
|
+
id: PREVIEW_GUIDE_OPEN_ID,
|
|
832
|
+
geometry: {
|
|
833
|
+
type: "LineString",
|
|
834
|
+
coordinates: spine.slice()
|
|
835
|
+
},
|
|
836
|
+
properties: {
|
|
837
|
+
part: "guide",
|
|
838
|
+
segment: "open"
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
features.push(open);
|
|
842
|
+
}
|
|
843
|
+
const alreadyClosed = spine.length >= 2 && spine[0][0] === spine[spine.length - 1][0] && spine[0][1] === spine[spine.length - 1][1];
|
|
844
|
+
if (isAreaKind && spine.length >= 3 && !alreadyClosed) {
|
|
845
|
+
const closing = {
|
|
846
|
+
type: "Feature",
|
|
847
|
+
id: PREVIEW_GUIDE_CLOSING_ID,
|
|
848
|
+
geometry: {
|
|
849
|
+
type: "LineString",
|
|
850
|
+
coordinates: [spine[spine.length - 1], spine[0]]
|
|
851
|
+
},
|
|
852
|
+
properties: {
|
|
853
|
+
part: "guide",
|
|
854
|
+
segment: "closing"
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
features.push(closing);
|
|
858
|
+
}
|
|
859
|
+
return features;
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
772
862
|
//#region src/tactical-draw/draw-controller.ts
|
|
773
863
|
const PREVIEW_CM_ID$1 = "__td_preview__";
|
|
774
864
|
const PREVIEW_CURSOR$1 = "crosshair";
|
|
@@ -786,7 +876,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
|
|
|
786
876
|
* kinds need no host UI.
|
|
787
877
|
*/
|
|
788
878
|
function runFixedLengthDraw(options) {
|
|
789
|
-
const { adapter, kind, previewLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, sizeAnchor, onSettled } = options;
|
|
879
|
+
const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
|
|
790
880
|
const metadata = getControlMeasureMetadata(kind);
|
|
791
881
|
const rule = metadata.rule;
|
|
792
882
|
const min = metadata.minCoordinates;
|
|
@@ -796,6 +886,8 @@ function runFixedLengthDraw(options) {
|
|
|
796
886
|
else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
|
|
797
887
|
else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
|
|
798
888
|
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
889
|
+
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
890
|
+
const isAreaKind = metadata.geometry === "area";
|
|
799
891
|
const committed = [];
|
|
800
892
|
let rubberBand;
|
|
801
893
|
let ctx;
|
|
@@ -820,11 +912,15 @@ function runFixedLengthDraw(options) {
|
|
|
820
912
|
const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
|
|
821
913
|
if (raw.length === 0) {
|
|
822
914
|
ctx.writeLayer(previewLayer, []);
|
|
915
|
+
ctx.writeLayer(guideLayer, []);
|
|
823
916
|
return;
|
|
824
917
|
}
|
|
825
918
|
const previewThreshold = rule?.minimumPreviewPoints ?? required;
|
|
826
919
|
const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
|
|
827
920
|
ctx.writeLayer(previewLayer, features);
|
|
921
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
|
|
922
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
|
|
923
|
+
ctx.writeLayer(guideLayer, guideFeatures);
|
|
828
924
|
}
|
|
829
925
|
function rescaleOnViewChange() {
|
|
830
926
|
if (!measureUsesAdapterResolution({
|
|
@@ -852,7 +948,7 @@ function runFixedLengthDraw(options) {
|
|
|
852
948
|
return runInteraction({
|
|
853
949
|
adapter,
|
|
854
950
|
signal,
|
|
855
|
-
layers: [previewLayer],
|
|
951
|
+
layers: [previewLayer, guideLayer],
|
|
856
952
|
onViewChange: rescaleOnViewChange,
|
|
857
953
|
onSettled,
|
|
858
954
|
attach(c) {
|
|
@@ -911,77 +1007,115 @@ function outlinePreview$1(points) {
|
|
|
911
1007
|
return features;
|
|
912
1008
|
}
|
|
913
1009
|
//#endregion
|
|
914
|
-
//#region src/tactical-draw/
|
|
915
|
-
/**
|
|
916
|
-
* Stable preview-layer ids and emission rules for the rubber-band guide
|
|
917
|
-
* (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
|
|
918
|
-
* single styling rule across both interactions.
|
|
919
|
-
*/
|
|
920
|
-
const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
|
|
921
|
-
const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
|
|
922
|
-
const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
|
|
1010
|
+
//#region src/tactical-draw/session-events.ts
|
|
923
1011
|
/**
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
1012
|
+
* Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
|
|
1013
|
+
* replays its own `reason` verbatim; anything else (a `produce` throw from
|
|
1014
|
+
* `ctx.commit`) is the commit-time snapshot build failing, reported as
|
|
1015
|
+
* `"error"`.
|
|
928
1016
|
*/
|
|
929
|
-
function
|
|
930
|
-
|
|
931
|
-
const min = metadata.minCoordinates;
|
|
932
|
-
const max = metadata.maxCoordinates;
|
|
933
|
-
return typeof min === "number" && typeof max === "number" && min === max;
|
|
1017
|
+
function settleReasonFromError(error) {
|
|
1018
|
+
return isTacticalDrawAbortError(error) ? error.reason : "error";
|
|
934
1019
|
}
|
|
935
1020
|
/**
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
1021
|
+
* Fan a `runInteraction` settlement out to a session's commit + settle
|
|
1022
|
+
* emitters — the shared body of the `onSettled` wrappers in the edit and draw
|
|
1023
|
+
* controllers (transform emits its close-path settle from `commitClose`
|
|
1024
|
+
* instead, so it does not use this). On the commit path, emit the snapshot
|
|
1025
|
+
* then the `"close"` settle in a try/finally so a throwing `onCommit` listener
|
|
1026
|
+
* cannot starve the settle emission; on the reject path, emit the settle with
|
|
1027
|
+
* the reason derived from the error. Callers run their own facade
|
|
1028
|
+
* slot-clearing (`onSettled()`) before this so a listener observes a cleared
|
|
1029
|
+
* `td.activeSession`.
|
|
939
1030
|
*/
|
|
940
|
-
function
|
|
941
|
-
|
|
1031
|
+
function settleFromInteraction(settlement, commitEmitter, settleEmitter) {
|
|
1032
|
+
if (settlement.status === "committed") try {
|
|
1033
|
+
commitEmitter.emit(settlement.value);
|
|
1034
|
+
} finally {
|
|
1035
|
+
settleEmitter.emit("close");
|
|
1036
|
+
}
|
|
1037
|
+
else settleEmitter.emit(settleReasonFromError(settlement.error));
|
|
942
1038
|
}
|
|
943
1039
|
/**
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
1040
|
+
* Subscribe/emit machinery shared by `TransformSession.onSettled`,
|
|
1041
|
+
* `EditSession.onSettled`, and `DrawSession.onSettled` (#191 / #194 / #197):
|
|
1042
|
+
* a settle-exactly-once latch, synchronous emission to every listener with
|
|
1043
|
+
* exception collection (single rethrow, `AggregateError` otherwise when more
|
|
1044
|
+
* than one listener throws), and a late-registration path that delivers the
|
|
1045
|
+
* latched reason on a microtask exactly once when a handler subscribes after
|
|
1046
|
+
* the session has already settled.
|
|
947
1047
|
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
1048
|
+
* `listenerErrorsMessage` becomes the `AggregateError` message — pass a
|
|
1049
|
+
* session-specific string (e.g. `"EditSession.onSettled listener errors"`).
|
|
950
1050
|
*/
|
|
951
|
-
function
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1051
|
+
function createSettleEmitter(listenerErrorsMessage) {
|
|
1052
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1053
|
+
/** Set exactly once, by `emit` — also the "has settled" latch and the reason replayed to late registrations. */
|
|
1054
|
+
let settledReason = null;
|
|
1055
|
+
return {
|
|
1056
|
+
emit(reason) {
|
|
1057
|
+
if (settledReason !== null) return;
|
|
1058
|
+
settledReason = reason;
|
|
1059
|
+
const current = [...listeners];
|
|
1060
|
+
listeners.clear();
|
|
1061
|
+
const errors = [];
|
|
1062
|
+
for (const listener of current) try {
|
|
1063
|
+
listener(reason);
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
errors.push(err);
|
|
965
1066
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
segment: "closing"
|
|
1067
|
+
if (errors.length === 1) throw errors[0];
|
|
1068
|
+
if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
|
|
1069
|
+
},
|
|
1070
|
+
register(handler) {
|
|
1071
|
+
if (settledReason !== null) {
|
|
1072
|
+
const reason = settledReason;
|
|
1073
|
+
let cancelled = false;
|
|
1074
|
+
queueMicrotask(() => {
|
|
1075
|
+
if (!cancelled) handler(reason);
|
|
1076
|
+
});
|
|
1077
|
+
return () => {
|
|
1078
|
+
cancelled = true;
|
|
1079
|
+
};
|
|
980
1080
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1081
|
+
listeners.add(handler);
|
|
1082
|
+
return () => {
|
|
1083
|
+
listeners.delete(handler);
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Subscribe/emit machinery shared by `TransformSession.onCommit`,
|
|
1090
|
+
* `EditSession.onCommit`, and `DrawSession.onCommit`: a listener set with
|
|
1091
|
+
* synchronous, exception-collecting emission (single rethrow, `AggregateError`
|
|
1092
|
+
* otherwise). Unlike `createSettleEmitter`, `emit` may be called more than
|
|
1093
|
+
* once — a transform's `setMembers` can commit several removal batches before
|
|
1094
|
+
* the close batch — and there is no latch or late-registration path:
|
|
1095
|
+
* registration-time `ctx.settled` guards stay in the controllers, mirroring
|
|
1096
|
+
* `onChange`.
|
|
1097
|
+
*/
|
|
1098
|
+
function createCommitEmitter(listenerErrorsMessage) {
|
|
1099
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1100
|
+
return {
|
|
1101
|
+
emit(value) {
|
|
1102
|
+
const current = [...listeners];
|
|
1103
|
+
const errors = [];
|
|
1104
|
+
for (const listener of current) try {
|
|
1105
|
+
listener(value);
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
errors.push(err);
|
|
1108
|
+
}
|
|
1109
|
+
if (errors.length === 1) throw errors[0];
|
|
1110
|
+
if (errors.length > 1) throw new AggregateError(errors, listenerErrorsMessage);
|
|
1111
|
+
},
|
|
1112
|
+
register(listener) {
|
|
1113
|
+
listeners.add(listener);
|
|
1114
|
+
return () => {
|
|
1115
|
+
listeners.delete(listener);
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
985
1119
|
}
|
|
986
1120
|
//#endregion
|
|
987
1121
|
//#region src/tactical-draw/draw-controller-variable.ts
|
|
@@ -1002,6 +1136,7 @@ function runVariableLengthDraw(options) {
|
|
|
1002
1136
|
const max = metadata.maxCoordinates;
|
|
1003
1137
|
const rule = metadata.rule;
|
|
1004
1138
|
const trailingFixed = rule?.trailingFixedSlots ?? 0;
|
|
1139
|
+
const closedRing = rule?.closedRing ?? false;
|
|
1005
1140
|
const ruleMin = rule?.minimumUserPoints;
|
|
1006
1141
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
1007
1142
|
const isAreaKind = metadata.geometry === "area";
|
|
@@ -1040,6 +1175,8 @@ function runVariableLengthDraw(options) {
|
|
|
1040
1175
|
let ctx;
|
|
1041
1176
|
const savedCursor = "";
|
|
1042
1177
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
1178
|
+
const commitEmitter = createCommitEmitter("DrawSession.onCommit listener errors");
|
|
1179
|
+
const settleEmitter = createSettleEmitter("DrawSession.onSettled listener errors");
|
|
1043
1180
|
function canCommitNow() {
|
|
1044
1181
|
const n = committed.length;
|
|
1045
1182
|
if (typeof max === "number" && n > max) return false;
|
|
@@ -1076,6 +1213,13 @@ function runVariableLengthDraw(options) {
|
|
|
1076
1213
|
return () => {
|
|
1077
1214
|
changeListeners.delete(listener);
|
|
1078
1215
|
};
|
|
1216
|
+
},
|
|
1217
|
+
onCommit(listener) {
|
|
1218
|
+
if (ctx.settled) return () => {};
|
|
1219
|
+
return commitEmitter.register(listener);
|
|
1220
|
+
},
|
|
1221
|
+
onSettled(handler) {
|
|
1222
|
+
return settleEmitter.register(handler);
|
|
1079
1223
|
}
|
|
1080
1224
|
};
|
|
1081
1225
|
function fireChange() {
|
|
@@ -1093,20 +1237,20 @@ function runVariableLengthDraw(options) {
|
|
|
1093
1237
|
if (ctx.settled) return;
|
|
1094
1238
|
const pos = roundControlPoint(event.coordinate);
|
|
1095
1239
|
const lastSpineIdx = committed.length - 1 - trailingFixed;
|
|
1240
|
+
const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
|
|
1096
1241
|
for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
|
|
1097
|
-
if (i
|
|
1242
|
+
if (finishIndices.includes(i) && canCommitNow()) ctx.commit(buildSnapshot);
|
|
1098
1243
|
return;
|
|
1099
1244
|
}
|
|
1100
|
-
if (lastSpineIdx >= 0 && canCommitNow()) {
|
|
1101
|
-
const
|
|
1102
|
-
if (
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1245
|
+
if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
|
|
1246
|
+
const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
|
|
1247
|
+
if (!finishPixel) continue;
|
|
1248
|
+
const tol = mapEventHitTolerance(event.originalEvent);
|
|
1249
|
+
const dx = event.pixel[0] - finishPixel[0];
|
|
1250
|
+
const dy = event.pixel[1] - finishPixel[1];
|
|
1251
|
+
if (dx * dx + dy * dy <= tol * tol) {
|
|
1252
|
+
ctx.commit(buildSnapshot);
|
|
1253
|
+
return;
|
|
1110
1254
|
}
|
|
1111
1255
|
}
|
|
1112
1256
|
if (typeof max === "number" && committed.length >= max) return;
|
|
@@ -1175,7 +1319,10 @@ function runVariableLengthDraw(options) {
|
|
|
1175
1319
|
signal,
|
|
1176
1320
|
layers: [previewLayer, guideLayer],
|
|
1177
1321
|
onViewChange: rescaleOnViewChange,
|
|
1178
|
-
onSettled
|
|
1322
|
+
onSettled: (settlement) => {
|
|
1323
|
+
if (onSettled) onSettled();
|
|
1324
|
+
settleFromInteraction(settlement, commitEmitter, settleEmitter);
|
|
1325
|
+
},
|
|
1179
1326
|
attach(c) {
|
|
1180
1327
|
ctx = c;
|
|
1181
1328
|
c.onTeardown(() => {
|
|
@@ -1246,63 +1393,14 @@ function outlinePreview(points) {
|
|
|
1246
1393
|
//#endregion
|
|
1247
1394
|
//#region src/tactical-draw/handles.ts
|
|
1248
1395
|
/**
|
|
1249
|
-
* Compute the anchor used by translate and rotate handles. The anchor is the
|
|
1250
|
-
* centroid (arithmetic mean) of the control-point list — stable, kind
|
|
1251
|
-
* agnostic, and well-defined for any non-empty point set.
|
|
1252
|
-
*/
|
|
1253
|
-
function computeHandleAnchor(controlPoints) {
|
|
1254
|
-
if (controlPoints.length === 0) return null;
|
|
1255
|
-
let sx = 0;
|
|
1256
|
-
let sy = 0;
|
|
1257
|
-
for (const p of controlPoints) {
|
|
1258
|
-
sx += p[0];
|
|
1259
|
-
sy += p[1];
|
|
1260
|
-
}
|
|
1261
|
-
return [sx / controlPoints.length, sy / controlPoints.length];
|
|
1262
|
-
}
|
|
1263
|
-
/**
|
|
1264
|
-
* How far past the outermost control point the rotate grip sits. `1.3` places
|
|
1265
|
-
* it 30% beyond the farthest vertex, clearing the geometry while staying
|
|
1266
|
-
* proportional to the shape's size.
|
|
1267
|
-
*/
|
|
1268
|
-
const ROTATE_HANDLE_REACH_FACTOR = 1.3;
|
|
1269
|
-
/**
|
|
1270
|
-
* Position of the rotate grip. Unlike the translate handle — which sits on the
|
|
1271
|
-
* centroid pivot — the rotate handle needs a lever arm, so it is offset from
|
|
1272
|
-
* the centroid along the ray toward the control point farthest from it, just
|
|
1273
|
-
* outside the shape. Anchoring to a *material* point (rather than the
|
|
1274
|
-
* axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
|
|
1275
|
-
* grip orbits with the shape as it rotates, since the farthest vertex rotates
|
|
1276
|
-
* with it. Falls back to the centroid when every point coincides (no
|
|
1277
|
-
* well-defined direction) or the list is empty.
|
|
1278
|
-
*/
|
|
1279
|
-
function computeRotateHandlePosition(controlPoints) {
|
|
1280
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1281
|
-
if (!anchor) return null;
|
|
1282
|
-
let farthest = null;
|
|
1283
|
-
let maxDistSq = 0;
|
|
1284
|
-
for (const p of controlPoints) {
|
|
1285
|
-
const dx = p[0] - anchor[0];
|
|
1286
|
-
const dy = p[1] - anchor[1];
|
|
1287
|
-
const distSq = dx * dx + dy * dy;
|
|
1288
|
-
if (distSq > maxDistSq) {
|
|
1289
|
-
maxDistSq = distSq;
|
|
1290
|
-
farthest = p;
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
if (!farthest) return anchor;
|
|
1294
|
-
return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
|
|
1295
|
-
}
|
|
1296
|
-
/**
|
|
1297
1396
|
* Axis-aligned bounding-box center of the control points — the midpoint of
|
|
1298
|
-
* `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1397
|
+
* `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
|
|
1398
|
+
* `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
|
|
1399
|
+
* the first paint), matching mainstream drawing tools' "turn about the visual
|
|
1400
|
+
* middle" convention. Returns `null` for an empty list.
|
|
1302
1401
|
*
|
|
1303
|
-
* Exported because the rotation pivot is *not* a handle
|
|
1304
|
-
*
|
|
1305
|
-
* controller computes it directly at drag-start. See ADR-0016.
|
|
1402
|
+
* Exported because the rotation pivot is *not* a handle — the edit controller
|
|
1403
|
+
* consults it directly. See ADR-0016/ADR-0017.
|
|
1306
1404
|
*/
|
|
1307
1405
|
function computeBoundingBoxCenter(controlPoints) {
|
|
1308
1406
|
if (controlPoints.length === 0) return null;
|
|
@@ -1328,26 +1426,26 @@ function segmentMidpoint(a, b) {
|
|
|
1328
1426
|
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1329
1427
|
}
|
|
1330
1428
|
/**
|
|
1331
|
-
* The single source of truth for
|
|
1332
|
-
* `EditMode → which handles` gating (ADR-0016). Pure and
|
|
1333
|
-
* positions are lonLat. Handles are emitted in **pick
|
|
1334
|
-
* `[vertices…, midpoints
|
|
1335
|
-
* first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1429
|
+
* The single source of truth for reshape-handle placement and the
|
|
1430
|
+
* `EditMode → which handles` gating (ADR-0016, narrowed by ADR-0017). Pure and
|
|
1431
|
+
* projection-free: positions are lonLat. Handles are emitted in **pick
|
|
1432
|
+
* priority order** — `[vertices…, midpoints…]` — so `pickHandle` can return
|
|
1433
|
+
* the first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1336
1434
|
* midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
|
|
1337
1435
|
* projections of this set; the drag controller seeds gestures from it too.
|
|
1338
1436
|
*
|
|
1339
1437
|
* Emits handles only for modes present in `modes`:
|
|
1340
1438
|
* - `reshape`: vertex handle at every control point + midpoint handle on
|
|
1341
1439
|
* every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
|
|
1342
|
-
* - `delete`: vertex handles only (tapped to remove), no
|
|
1343
|
-
* - `translate`: a single translate handle at the centroid anchor;
|
|
1344
|
-
* - `rotate`: a single rotate grip offset outside the shape.
|
|
1440
|
+
* - `delete`: vertex handles only (tapped to remove), no midpoints.
|
|
1345
1441
|
*
|
|
1346
|
-
* `
|
|
1347
|
-
* are
|
|
1442
|
+
* `transform` mode never contributes handles here — the box's corner/rotate
|
|
1443
|
+
* grips are pixel-space and placed by `transform-box.ts`, then rendered as
|
|
1444
|
+
* `Handle`s of kind `scale`/`rotate` by the edit controller. Returns `[]` for
|
|
1445
|
+
* an empty control-point list.
|
|
1348
1446
|
*/
|
|
1349
1447
|
function buildHandleSet(controlPoints, modes, options = {}) {
|
|
1350
|
-
const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
|
|
1448
|
+
const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
|
|
1351
1449
|
const handles = [];
|
|
1352
1450
|
const wantsReshape = modes.includes("reshape");
|
|
1353
1451
|
if (wantsReshape || modes.includes("delete")) {
|
|
@@ -1357,37 +1455,24 @@ function buildHandleSet(controlPoints, modes, options = {}) {
|
|
|
1357
1455
|
position: [...controlPoints[i]]
|
|
1358
1456
|
});
|
|
1359
1457
|
if (wantsReshape && includeMidpoints) {
|
|
1360
|
-
const
|
|
1361
|
-
for (let i = 0; i <
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1458
|
+
const edgeCount = closedRing && trailingFixedSlots === 0 && controlPoints.length >= 3 ? controlPoints.length : controlPoints.length - 1 - trailingFixedSlots;
|
|
1459
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
1460
|
+
const end = (i + 1) % controlPoints.length;
|
|
1461
|
+
handles.push({
|
|
1462
|
+
kind: "midpoint",
|
|
1463
|
+
index: i,
|
|
1464
|
+
position: segmentMidpoint(controlPoints[i], controlPoints[end])
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1366
1467
|
}
|
|
1367
1468
|
}
|
|
1368
|
-
if (modes.includes("translate")) {
|
|
1369
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1370
|
-
if (anchor) handles.push({
|
|
1371
|
-
kind: "translate",
|
|
1372
|
-
index: 0,
|
|
1373
|
-
position: anchor
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
if (modes.includes("rotate")) {
|
|
1377
|
-
const rotatePos = computeRotateHandlePosition(controlPoints);
|
|
1378
|
-
if (rotatePos) handles.push({
|
|
1379
|
-
kind: "rotate",
|
|
1380
|
-
index: 0,
|
|
1381
|
-
position: rotatePos
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
1469
|
return handles;
|
|
1385
1470
|
}
|
|
1386
1471
|
function handleFeatureId(prefix, kind, index) {
|
|
1387
1472
|
switch (kind) {
|
|
1388
1473
|
case "vertex": return `${prefix}:handle-vertex:${index}`;
|
|
1389
1474
|
case "midpoint": return `${prefix}:handle-midpoint:${index}`;
|
|
1390
|
-
case "
|
|
1475
|
+
case "scale": return `${prefix}:handle-scale:${index}`;
|
|
1391
1476
|
case "rotate": return `${prefix}:handle-rotate`;
|
|
1392
1477
|
}
|
|
1393
1478
|
}
|
|
@@ -1395,16 +1480,18 @@ function handleStyleFor(kind, interactionStyle) {
|
|
|
1395
1480
|
switch (kind) {
|
|
1396
1481
|
case "vertex": return interactionStyle?.vertexHandle;
|
|
1397
1482
|
case "midpoint": return interactionStyle?.midpointHandle;
|
|
1398
|
-
case "
|
|
1483
|
+
case "scale": return interactionStyle?.scaleHandle;
|
|
1399
1484
|
case "rotate": return interactionStyle?.rotateHandle;
|
|
1400
1485
|
}
|
|
1401
1486
|
}
|
|
1402
1487
|
/**
|
|
1403
1488
|
* Project a handle set into the GeoJSON features that belong on the handles
|
|
1404
|
-
* layer — the render projection of `buildHandleSet` (ADR-0016)
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1489
|
+
* layer — the render projection of `buildHandleSet` (ADR-0016), also reused by
|
|
1490
|
+
* the edit controller to render the transform box's corner/rotate `Handle`s
|
|
1491
|
+
* (ADR-0017). Stable `featureIdPrefix`-namespaced ids let multiple edits share
|
|
1492
|
+
* a handles layer without colliding; per-slot `interactionStyle` rides on
|
|
1493
|
+
* `properties.style`. Pure: handle positions are copied, never aliased into
|
|
1494
|
+
* the output geometry.
|
|
1408
1495
|
*/
|
|
1409
1496
|
function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
1410
1497
|
return handles.map((handle) => {
|
|
@@ -1427,24 +1514,140 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
|
1427
1514
|
};
|
|
1428
1515
|
});
|
|
1429
1516
|
}
|
|
1430
|
-
function pixelDistance(a, b) {
|
|
1517
|
+
function pixelDistance$2(a, b) {
|
|
1431
1518
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1432
1519
|
}
|
|
1433
1520
|
/**
|
|
1434
1521
|
* The pick projection of `buildHandleSet` (ADR-0016): the first handle within
|
|
1435
1522
|
* `tolerance` pixels of `pixel`, tested in the set's priority order. Because
|
|
1436
|
-
* `buildHandleSet` orders vertices before midpoints
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1523
|
+
* `buildHandleSet` orders vertices before midpoints, "first within tolerance"
|
|
1524
|
+
* preserves the legacy "vertex wins over a pixel-closer midpoint" behaviour.
|
|
1525
|
+
* Returns the full `Handle` so the drag controller can seed a gesture from its
|
|
1526
|
+
* `position` without recomputing placement. Handles whose position fails to
|
|
1527
|
+
* project (off-screen / degenerate projection) are skipped. Returns `null`
|
|
1528
|
+
* when nothing is within tolerance.
|
|
1442
1529
|
*/
|
|
1443
1530
|
function pickHandle(handles, pixel, tolerance, toPixel) {
|
|
1444
1531
|
for (const handle of handles) {
|
|
1445
1532
|
const projected = toPixel(handle.position);
|
|
1446
|
-
if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
|
|
1533
|
+
if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
|
|
1534
|
+
}
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
function rotate(x, y, angle) {
|
|
1538
|
+
const cos = Math.cos(angle);
|
|
1539
|
+
const sin = Math.sin(angle);
|
|
1540
|
+
return [x * cos - y * sin, x * sin + y * cos];
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
|
|
1544
|
+
* Rotates the points by `-angle` about their centroid, takes the axis-aligned
|
|
1545
|
+
* bounding box in that de-rotated frame, then re-expresses the result as an
|
|
1546
|
+
* oriented box at `angle` — so the box always has exactly the requested tilt,
|
|
1547
|
+
* sized to just contain the (de-rotated) points. Half-dimensions are floored
|
|
1548
|
+
* at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
|
|
1549
|
+
* pickable box. Returns `null` for an empty list — there is nothing to fit.
|
|
1550
|
+
*
|
|
1551
|
+
* `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
|
|
1552
|
+
* the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
|
|
1553
|
+
* `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
|
|
1554
|
+
* the rendered geometry. Everything downstream that reads a fitted box
|
|
1555
|
+
* (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
|
|
1556
|
+
* `halfWidth`/`halfHeight` alone, so the padding flows through to corner
|
|
1557
|
+
* placement, the rotate grip, and picking automatically — no other function
|
|
1558
|
+
* needs to know about it.
|
|
1559
|
+
*/
|
|
1560
|
+
function fitTransformBox(pixels, angle, padding = 0) {
|
|
1561
|
+
if (pixels.length === 0) return null;
|
|
1562
|
+
let cx = 0;
|
|
1563
|
+
let cy = 0;
|
|
1564
|
+
for (const p of pixels) {
|
|
1565
|
+
cx += p[0];
|
|
1566
|
+
cy += p[1];
|
|
1567
|
+
}
|
|
1568
|
+
cx /= pixels.length;
|
|
1569
|
+
cy /= pixels.length;
|
|
1570
|
+
let minX = Infinity;
|
|
1571
|
+
let minY = Infinity;
|
|
1572
|
+
let maxX = -Infinity;
|
|
1573
|
+
let maxY = -Infinity;
|
|
1574
|
+
for (const p of pixels) {
|
|
1575
|
+
const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
|
|
1576
|
+
if (rx < minX) minX = rx;
|
|
1577
|
+
if (rx > maxX) maxX = rx;
|
|
1578
|
+
if (ry < minY) minY = ry;
|
|
1579
|
+
if (ry > maxY) maxY = ry;
|
|
1447
1580
|
}
|
|
1581
|
+
const localCenterX = (minX + maxX) / 2;
|
|
1582
|
+
const localCenterY = (minY + maxY) / 2;
|
|
1583
|
+
const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
|
|
1584
|
+
const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
|
|
1585
|
+
const [ox, oy] = rotate(localCenterX, localCenterY, angle);
|
|
1586
|
+
return {
|
|
1587
|
+
center: [cx + ox, cy + oy],
|
|
1588
|
+
halfWidth,
|
|
1589
|
+
halfHeight,
|
|
1590
|
+
angle
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
|
|
1595
|
+
* bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
|
|
1596
|
+
* `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
|
|
1597
|
+
* and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
|
|
1598
|
+
* anchor is always the opposite corner, `(index + 2) % 4`.
|
|
1599
|
+
*/
|
|
1600
|
+
function boxCorners(box) {
|
|
1601
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1602
|
+
return [
|
|
1603
|
+
[-halfWidth, -halfHeight],
|
|
1604
|
+
[halfWidth, -halfHeight],
|
|
1605
|
+
[halfWidth, halfHeight],
|
|
1606
|
+
[-halfWidth, halfHeight]
|
|
1607
|
+
].map(([x, y]) => {
|
|
1608
|
+
const [rx, ry] = rotate(x, y, angle);
|
|
1609
|
+
return [center[0] + rx, center[1] + ry];
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
|
|
1614
|
+
* midpoint, along the box's local "up" direction, so the grip stays glued to
|
|
1615
|
+
* the box (rotates with it) rather than floating at a fixed screen offset.
|
|
1616
|
+
*/
|
|
1617
|
+
function rotateGripPixel(box) {
|
|
1618
|
+
const { center, halfHeight, angle } = box;
|
|
1619
|
+
const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
|
|
1620
|
+
return [center[0] + rx, center[1] + ry];
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Point-in-oriented-rectangle test, used to start a body (translate) drag
|
|
1624
|
+
* when the pointer goes down inside the box but not on a corner/rotate grip.
|
|
1625
|
+
* De-rotates `pixel` into the box's local frame about `box.center` and
|
|
1626
|
+
* compares against the half-extents.
|
|
1627
|
+
*/
|
|
1628
|
+
function pointInBox(box, pixel) {
|
|
1629
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1630
|
+
const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
|
|
1631
|
+
return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
|
|
1632
|
+
}
|
|
1633
|
+
function pixelDistance$1(a, b) {
|
|
1634
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Pick what part of the box, if any, `pixel` hits — corners first, then the
|
|
1638
|
+
* rotate grip, then plain containment (body drag). Corners and the rotate
|
|
1639
|
+
* grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
|
|
1640
|
+
* click just outside the box never starts a body drag. Returns `null` when
|
|
1641
|
+
* nothing matches.
|
|
1642
|
+
*/
|
|
1643
|
+
function pickBoxHandle(box, pixel, tolerance) {
|
|
1644
|
+
const corners = boxCorners(box);
|
|
1645
|
+
for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
|
|
1646
|
+
kind: "scale",
|
|
1647
|
+
index: i
|
|
1648
|
+
};
|
|
1649
|
+
if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
|
|
1650
|
+
if (pointInBox(box, pixel)) return { kind: "body" };
|
|
1448
1651
|
return null;
|
|
1449
1652
|
}
|
|
1450
1653
|
//#endregion
|
|
@@ -1460,17 +1663,8 @@ function translatePoints(base, dx, dy) {
|
|
|
1460
1663
|
return out;
|
|
1461
1664
|
});
|
|
1462
1665
|
}
|
|
1463
|
-
function
|
|
1464
|
-
return Math.
|
|
1465
|
-
}
|
|
1466
|
-
/**
|
|
1467
|
-
* Angle of `pixel` around the rotation pivot, measured in screen space so it
|
|
1468
|
-
* matches what the user sees and does. Falls back to lon/lat when the pivot
|
|
1469
|
-
* cannot be projected — exceedingly rare for an on-screen edit.
|
|
1470
|
-
*/
|
|
1471
|
-
function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
|
|
1472
|
-
if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
|
|
1473
|
-
return angleFromAnchor(pivot, projection.fromPixel(pixel));
|
|
1666
|
+
function pixelDistance(a, b) {
|
|
1667
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1474
1668
|
}
|
|
1475
1669
|
/**
|
|
1476
1670
|
* Rotate `points` by `angle` about the pivot in screen space — project each
|
|
@@ -1528,7 +1722,7 @@ function begun(kind, initialBase, move) {
|
|
|
1528
1722
|
* caller's working copy.
|
|
1529
1723
|
*/
|
|
1530
1724
|
function beginHandleDrag(args) {
|
|
1531
|
-
const { hit,
|
|
1725
|
+
const { hit, points, projection, rule } = args;
|
|
1532
1726
|
const { fromPixel } = projection;
|
|
1533
1727
|
const preDragPoints = clonePoints(points);
|
|
1534
1728
|
const snap = (next, activePointIndex) => rule ? rule.transform({
|
|
@@ -1538,45 +1732,103 @@ function beginHandleDrag(args) {
|
|
|
1538
1732
|
}) : next;
|
|
1539
1733
|
const dragOnePoint = (base, activeIndex) => {
|
|
1540
1734
|
const move = (pixel) => {
|
|
1541
|
-
return {
|
|
1542
|
-
points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
|
|
1543
|
-
rotateGripOverride: null
|
|
1544
|
-
};
|
|
1735
|
+
return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
|
|
1545
1736
|
};
|
|
1546
1737
|
return move;
|
|
1547
1738
|
};
|
|
1548
1739
|
if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1740
|
+
const insertIndex = hit.index + 1;
|
|
1741
|
+
const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
|
|
1742
|
+
const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
|
|
1743
|
+
const insertedBase = [
|
|
1744
|
+
...preDragPoints.slice(0, insertIndex),
|
|
1745
|
+
mid,
|
|
1746
|
+
...preDragPoints.slice(insertIndex)
|
|
1747
|
+
];
|
|
1748
|
+
return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
|
|
1749
|
+
}
|
|
1750
|
+
/** No corner drag may scale below this factor — guards against reflection/collapse through the anchor. */
|
|
1751
|
+
const MIN_SCALE_FACTOR = .05;
|
|
1752
|
+
/**
|
|
1753
|
+
* Begin a transform-box drag: uniform corner scale, box-center rotate, or
|
|
1754
|
+
* body translate. Mirrors `beginHandleDrag`'s shape (a `move`/`end` FSM seeded
|
|
1755
|
+
* from the pre-drag points) but keyed on `BoxDragKind` instead of `HandleKind`,
|
|
1756
|
+
* and additionally threading the box itself through so the caller can paint
|
|
1757
|
+
* the live box without re-fitting from projected geometry every frame.
|
|
1758
|
+
*/
|
|
1759
|
+
function beginBoxDrag(args) {
|
|
1760
|
+
const { kind, downPixel, points, projection, box, cornerIndex } = args;
|
|
1761
|
+
const { toPixel, fromPixel } = projection;
|
|
1762
|
+
const preDragPoints = clonePoints(points);
|
|
1763
|
+
if (kind === "scale") {
|
|
1764
|
+
const index = cornerIndex ?? 0;
|
|
1765
|
+
const anchor = boxCorners(box)[(index + 2) % 4];
|
|
1766
|
+
const downDist = pixelDistance(downPixel, anchor);
|
|
1767
|
+
const move = (pixel) => {
|
|
1768
|
+
const dist = pixelDistance(pixel, anchor);
|
|
1769
|
+
const rawFactor = downDist === 0 ? 1 : dist / downDist;
|
|
1770
|
+
const factor = Math.max(rawFactor, MIN_SCALE_FACTOR);
|
|
1563
1771
|
return {
|
|
1564
|
-
points:
|
|
1565
|
-
|
|
1772
|
+
points: preDragPoints.map((p) => {
|
|
1773
|
+
const px = toPixel(p);
|
|
1774
|
+
if (!px) return [...p];
|
|
1775
|
+
const ll = fromPixel([anchor[0] + (px[0] - anchor[0]) * factor, anchor[1] + (px[1] - anchor[1]) * factor]);
|
|
1776
|
+
const out = [ll[0], ll[1]];
|
|
1777
|
+
for (let k = 2; k < p.length; k++) out.push(p[k]);
|
|
1778
|
+
return out;
|
|
1779
|
+
}),
|
|
1780
|
+
box: {
|
|
1781
|
+
center: [anchor[0] + (box.center[0] - anchor[0]) * factor, anchor[1] + (box.center[1] - anchor[1]) * factor],
|
|
1782
|
+
halfWidth: box.halfWidth * factor,
|
|
1783
|
+
halfHeight: box.halfHeight * factor,
|
|
1784
|
+
angle: box.angle
|
|
1785
|
+
}
|
|
1566
1786
|
};
|
|
1567
|
-
}
|
|
1787
|
+
};
|
|
1788
|
+
return {
|
|
1789
|
+
kind,
|
|
1790
|
+
move,
|
|
1791
|
+
end: (pixel) => move(pixel).points
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
if (kind === "rotate") {
|
|
1795
|
+
const pivotPixel = box.center;
|
|
1796
|
+
const pivot = fromPixel(pivotPixel);
|
|
1797
|
+
const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
|
|
1798
|
+
const move = (pixel) => {
|
|
1799
|
+
const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
|
|
1800
|
+
return {
|
|
1801
|
+
points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
|
|
1802
|
+
box: {
|
|
1803
|
+
center: [...pivotPixel],
|
|
1804
|
+
halfWidth: box.halfWidth,
|
|
1805
|
+
halfHeight: box.halfHeight,
|
|
1806
|
+
angle: box.angle + angle
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
};
|
|
1810
|
+
return {
|
|
1811
|
+
kind,
|
|
1812
|
+
move,
|
|
1813
|
+
end: (pixel) => move(pixel).points
|
|
1814
|
+
};
|
|
1568
1815
|
}
|
|
1569
|
-
const
|
|
1570
|
-
const
|
|
1571
|
-
|
|
1572
|
-
const startGrip = hit.position;
|
|
1573
|
-
return begun("rotate", preDragPoints, (pixel) => {
|
|
1574
|
-
const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
|
|
1816
|
+
const origin = fromPixel(downPixel);
|
|
1817
|
+
const move = (pixel) => {
|
|
1818
|
+
const cur = fromPixel(pixel);
|
|
1575
1819
|
return {
|
|
1576
|
-
points:
|
|
1577
|
-
|
|
1820
|
+
points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
|
|
1821
|
+
box: {
|
|
1822
|
+
...box,
|
|
1823
|
+
center: [box.center[0] + (pixel[0] - downPixel[0]), box.center[1] + (pixel[1] - downPixel[1])]
|
|
1824
|
+
}
|
|
1578
1825
|
};
|
|
1579
|
-
}
|
|
1826
|
+
};
|
|
1827
|
+
return {
|
|
1828
|
+
kind,
|
|
1829
|
+
move,
|
|
1830
|
+
end: (pixel) => move(pixel).points
|
|
1831
|
+
};
|
|
1580
1832
|
}
|
|
1581
1833
|
//#endregion
|
|
1582
1834
|
//#region src/tactical-draw/edit-session.ts
|
|
@@ -1598,8 +1850,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
|
|
|
1598
1850
|
const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
|
|
1599
1851
|
/**
|
|
1600
1852
|
* Internal test seam — simulate a completed rotate gesture by passing an angle
|
|
1601
|
-
* in radians. Every control point rotates around the
|
|
1602
|
-
*
|
|
1853
|
+
* in radians. Every control point rotates around the transform box's center
|
|
1854
|
+
* (falling back to the control-points' bounding-box center when no box has
|
|
1855
|
+
* been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
|
|
1856
|
+
* the same delta a live rotate box-drag would (ADR-0017).
|
|
1603
1857
|
*
|
|
1604
1858
|
* @internal
|
|
1605
1859
|
*/
|
|
@@ -1620,6 +1874,78 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
|
|
|
1620
1874
|
*/
|
|
1621
1875
|
const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
1622
1876
|
//#endregion
|
|
1877
|
+
//#region src/tactical-draw/box-features.ts
|
|
1878
|
+
/**
|
|
1879
|
+
* Rendering helpers shared by the single-measure edit controller
|
|
1880
|
+
* (`edit-controller.ts`) and the group transform controller
|
|
1881
|
+
* (`transform-controller.ts`): both paint the same `TransformBox` shape —
|
|
1882
|
+
* corner scale handles, a rotate grip, and a closed-ring outline — so the
|
|
1883
|
+
* projection from pure box geometry (`transform-box.ts`) to lonLat `Handle`s
|
|
1884
|
+
* / GeoJSON features lives in one place instead of two copies drifting apart.
|
|
1885
|
+
* See ADR-0017.
|
|
1886
|
+
*/
|
|
1887
|
+
/** Walk a GeoJSON `Geometry`'s coordinate arrays, pushing every `Position` onto `out`. */
|
|
1888
|
+
function collectFeaturePositions(geometry, out) {
|
|
1889
|
+
switch (geometry.type) {
|
|
1890
|
+
case "Point":
|
|
1891
|
+
out.push(geometry.coordinates);
|
|
1892
|
+
break;
|
|
1893
|
+
case "MultiPoint":
|
|
1894
|
+
case "LineString":
|
|
1895
|
+
for (const p of geometry.coordinates) out.push(p);
|
|
1896
|
+
break;
|
|
1897
|
+
case "MultiLineString":
|
|
1898
|
+
case "Polygon":
|
|
1899
|
+
for (const ring of geometry.coordinates) for (const p of ring) out.push(p);
|
|
1900
|
+
break;
|
|
1901
|
+
case "MultiPolygon":
|
|
1902
|
+
for (const poly of geometry.coordinates) for (const ring of poly) for (const p of ring) out.push(p);
|
|
1903
|
+
break;
|
|
1904
|
+
case "GeometryCollection":
|
|
1905
|
+
for (const g of geometry.geometries) collectFeaturePositions(g, out);
|
|
1906
|
+
break;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Project a `TransformBox`'s 4 corner scale handles and rotate grip into
|
|
1911
|
+
* lonLat `Handle`s via `fromPixel` — the render projection of the box's
|
|
1912
|
+
* pixel-space geometry (`boxCorners` / `rotateGripPixel`), reused by both
|
|
1913
|
+
* controllers so box-handle placement can't drift between single-measure and
|
|
1914
|
+
* group transforms.
|
|
1915
|
+
*/
|
|
1916
|
+
function boxToHandles(box, fromPixel) {
|
|
1917
|
+
const handles = boxCorners(box).map((corner, index) => ({
|
|
1918
|
+
kind: "scale",
|
|
1919
|
+
index,
|
|
1920
|
+
position: fromPixel(corner)
|
|
1921
|
+
}));
|
|
1922
|
+
handles.push({
|
|
1923
|
+
kind: "rotate",
|
|
1924
|
+
index: 0,
|
|
1925
|
+
position: fromPixel(rotateGripPixel(box))
|
|
1926
|
+
});
|
|
1927
|
+
return handles;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Build the closed `LineString` outline feature for `box`, styled from
|
|
1931
|
+
* `style` (delivered per-feature via `properties.style`, as the adapters
|
|
1932
|
+
* already honor for line features). `featureId` is the caller's stable id —
|
|
1933
|
+
* `${prefix}:box-outline` for a single-measure or group box, or
|
|
1934
|
+
* `${prefix}:member-outline:{measureId}` for a group member's own outline.
|
|
1935
|
+
*/
|
|
1936
|
+
function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
1937
|
+
const corners = boxCorners(box).map((c) => fromPixel(c));
|
|
1938
|
+
return {
|
|
1939
|
+
type: "Feature",
|
|
1940
|
+
id: featureId,
|
|
1941
|
+
geometry: {
|
|
1942
|
+
type: "LineString",
|
|
1943
|
+
coordinates: [...corners, corners[0]]
|
|
1944
|
+
},
|
|
1945
|
+
properties: style ? { style: { ...style } } : {}
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
//#endregion
|
|
1623
1949
|
//#region src/tactical-draw/edit-controller.ts
|
|
1624
1950
|
/**
|
|
1625
1951
|
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
@@ -1628,27 +1954,32 @@ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
|
1628
1954
|
* dirty tracking; the constant lives in one place so future slices can lift
|
|
1629
1955
|
* it to an option without touching call sites.
|
|
1630
1956
|
*/
|
|
1631
|
-
const DIRTY_EPSILON = 1e-9;
|
|
1632
|
-
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
1957
|
+
const DIRTY_EPSILON$1 = 1e-9;
|
|
1958
|
+
const HANDLE_HIT_TOLERANCE_PX$1 = 12;
|
|
1959
|
+
/**
|
|
1960
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
1961
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
1962
|
+
* click, not a gesture: it is not committed (geometry restored, no
|
|
1963
|
+
* `EditChangeEvent`, `dirty` untouched) but the click-away listener is
|
|
1964
|
+
* suppressed so the click doesn't close the edit. Corner/rotate drags commit
|
|
1965
|
+
* even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
|
|
1966
|
+
*/
|
|
1967
|
+
const CLICK_SLOP_PX$1 = 3;
|
|
1633
1968
|
const ALLOWED_EDIT_MODES = new Set([
|
|
1634
1969
|
"reshape",
|
|
1635
|
-
"
|
|
1636
|
-
"rotate",
|
|
1970
|
+
"transform",
|
|
1637
1971
|
"delete"
|
|
1638
1972
|
]);
|
|
1639
1973
|
/**
|
|
1640
|
-
* Drive an interactive edit against a `MapAdapter`. Implements the
|
|
1641
|
-
*
|
|
1974
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
1975
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
1642
1976
|
*
|
|
1643
1977
|
* - lift-original (remove from graphics layer) + working copy
|
|
1644
|
-
* - preview + handle rendering
|
|
1978
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
1979
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
1645
1980
|
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
1646
1981
|
* - default click-away close policy
|
|
1647
1982
|
* - close / abort / signal / preempt / destroy paths
|
|
1648
|
-
*
|
|
1649
|
-
* Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
|
|
1650
|
-
* gesture binding lands with the change-event spec in #49 and the
|
|
1651
|
-
* translate/rotate handle set in #50.
|
|
1652
1983
|
*/
|
|
1653
1984
|
function runEdit(opts) {
|
|
1654
1985
|
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
@@ -1658,6 +1989,7 @@ function runEdit(opts) {
|
|
|
1658
1989
|
const rule = metadata.rule;
|
|
1659
1990
|
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
1660
1991
|
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
1992
|
+
const closedRing = rule?.closedRing ?? false;
|
|
1661
1993
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
1662
1994
|
const isAreaKind = metadata.geometry === "area";
|
|
1663
1995
|
const requestedModes = normaliseModes(initialModes);
|
|
@@ -1688,12 +2020,57 @@ function runEdit(opts) {
|
|
|
1688
2020
|
*/
|
|
1689
2021
|
let pendingModes = null;
|
|
1690
2022
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
2023
|
+
const commitEmitter = createCommitEmitter("EditSession.onCommit listener errors");
|
|
2024
|
+
const settleEmitter = createSettleEmitter("EditSession.onSettled listener errors");
|
|
2025
|
+
/**
|
|
2026
|
+
* The transform box's rotation, in radians, session-scoped (ADR-0017 decision
|
|
2027
|
+
* #2): starts at `0` (axis-aligned) and accumulates every committed rotate
|
|
2028
|
+
* gesture's delta, so the box stays glued to the shape across gestures. Never
|
|
2029
|
+
* persisted on the measure; a fresh edit always starts back at `0`.
|
|
2030
|
+
*/
|
|
2031
|
+
let boxAngle = 0;
|
|
2032
|
+
/**
|
|
2033
|
+
* The transform box fitted around the *rendered* working-copy geometry
|
|
2034
|
+
* (pixel space), or `null` when nothing projected this frame. Re-fitted
|
|
2035
|
+
* after every committed gesture, every reshape-drag paint, and on
|
|
2036
|
+
* `onViewChange`; painted rigidly from the live drag's own snapshot instead
|
|
2037
|
+
* while a box gesture (`activeBoxDrag`) is in flight.
|
|
2038
|
+
*/
|
|
2039
|
+
let box = null;
|
|
2040
|
+
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
2041
|
+
let lastPreviewFeatures = [];
|
|
1691
2042
|
let ctx;
|
|
1692
2043
|
let driver;
|
|
1693
2044
|
const dragProjection = {
|
|
1694
2045
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
1695
2046
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
1696
2047
|
};
|
|
2048
|
+
/**
|
|
2049
|
+
* Fit the transform box around `features`' rendered geometry: every
|
|
2050
|
+
* coordinate is projected to pixels via the pointer driver and
|
|
2051
|
+
* `fitTransformBox`'d at the current `boxAngle`. `null` when nothing
|
|
2052
|
+
* projects (e.g. the working copy renders no features this frame).
|
|
2053
|
+
*/
|
|
2054
|
+
function fitBoxFromFeatures(features) {
|
|
2055
|
+
const positions = [];
|
|
2056
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
2057
|
+
const pixels = [];
|
|
2058
|
+
for (const p of positions) {
|
|
2059
|
+
const px = driver.toPixel(p);
|
|
2060
|
+
if (px) pixels.push(px);
|
|
2061
|
+
}
|
|
2062
|
+
return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
|
|
2066
|
+
* in flight — that drag paints the box rigidly from its own snapshot
|
|
2067
|
+
* (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
|
|
2068
|
+
* doesn't fight its own paint.
|
|
2069
|
+
*/
|
|
2070
|
+
function refitBox() {
|
|
2071
|
+
if (activeBoxDrag) return;
|
|
2072
|
+
box = fitBoxFromFeatures(lastPreviewFeatures);
|
|
2073
|
+
}
|
|
1697
2074
|
function liftOriginalFromGraphics() {
|
|
1698
2075
|
if (!wasOnGraphicsLayer) return;
|
|
1699
2076
|
const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
@@ -1714,37 +2091,48 @@ function runEdit(opts) {
|
|
|
1714
2091
|
features = [];
|
|
1715
2092
|
}
|
|
1716
2093
|
ctx.writeLayer(previewLayer, features);
|
|
1717
|
-
|
|
2094
|
+
lastPreviewFeatures = features;
|
|
2095
|
+
refitBox();
|
|
2096
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
|
|
2097
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
|
|
1718
2098
|
ctx.writeLayer(guideLayer, guideFeatures);
|
|
1719
2099
|
}
|
|
1720
2100
|
function rescaleOnViewChange() {
|
|
1721
|
-
if (
|
|
2101
|
+
if (ctx.settled) return;
|
|
2102
|
+
if (measureUsesAdapterResolution({
|
|
1722
2103
|
kind: measure.kind,
|
|
1723
2104
|
options: workingOptions
|
|
1724
|
-
}))
|
|
1725
|
-
|
|
2105
|
+
})) renderPreview();
|
|
2106
|
+
else refitBox();
|
|
2107
|
+
renderHandlesLayer();
|
|
1726
2108
|
}
|
|
1727
2109
|
function renderHandlesLayer() {
|
|
1728
2110
|
if (ctx.settled) return;
|
|
1729
|
-
|
|
2111
|
+
const features = toFeatures(buildHandleSet(workingPoints, modes, {
|
|
1730
2112
|
includeMidpoints: midpointHandlesEnabled,
|
|
1731
|
-
trailingFixedSlots
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
2113
|
+
trailingFixedSlots,
|
|
2114
|
+
closedRing
|
|
2115
|
+
}), handleIdPrefix, interactionStyle);
|
|
2116
|
+
if (modes.includes("transform")) {
|
|
2117
|
+
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
2118
|
+
if (liveBox) {
|
|
2119
|
+
features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
|
|
2120
|
+
features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1741
2123
|
ctx.writeLayer(handlesLayer, features);
|
|
1742
2124
|
}
|
|
1743
|
-
|
|
2125
|
+
/**
|
|
2126
|
+
* Builds the committed snapshot against explicit `options` rather than the
|
|
2127
|
+
* live `workingOptions` — used to render a gesture's "previous" snapshot
|
|
2128
|
+
* against pre-freeze options when a rotate has already merged its
|
|
2129
|
+
* orientation freeze into `workingOptions` by the time the commit runs.
|
|
2130
|
+
*/
|
|
2131
|
+
function rebuildRenderedWith(options) {
|
|
1744
2132
|
const working = {
|
|
1745
2133
|
...measure,
|
|
1746
2134
|
style: currentStyle,
|
|
1747
|
-
options
|
|
2135
|
+
options,
|
|
1748
2136
|
controlPoints: workingPoints.map((p) => [...p])
|
|
1749
2137
|
};
|
|
1750
2138
|
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
@@ -1753,8 +2141,11 @@ function runEdit(opts) {
|
|
|
1753
2141
|
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
1754
2142
|
};
|
|
1755
2143
|
}
|
|
2144
|
+
function rebuildRendered() {
|
|
2145
|
+
return rebuildRenderedWith(workingOptions);
|
|
2146
|
+
}
|
|
1756
2147
|
function recomputeDirty() {
|
|
1757
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
|
|
2148
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
|
|
1758
2149
|
}
|
|
1759
2150
|
function replaceWorkingPoints(next) {
|
|
1760
2151
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -1763,6 +2154,23 @@ function runEdit(opts) {
|
|
|
1763
2154
|
workingOptions = { ...next ?? {} };
|
|
1764
2155
|
}
|
|
1765
2156
|
/**
|
|
2157
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
2158
|
+
* anchor) to their current concrete values, merging any patch onto
|
|
2159
|
+
* `workingOptions` in place. Called the moment a rotate gesture starts (a
|
|
2160
|
+
* box-drag rotate hit, or the `commitRotate` test seam) — a rigid rotation
|
|
2161
|
+
* of `workingPoints` follows immediately after, so the freeze must land
|
|
2162
|
+
* first or the orientation-relative default would re-derive against the
|
|
2163
|
+
* rotated geometry (ADR-0023). No dirty/change-event side effects of its
|
|
2164
|
+
* own — the rotate gesture's own commit carries the frozen options.
|
|
2165
|
+
*/
|
|
2166
|
+
function freezeOrientationForRotate() {
|
|
2167
|
+
const patch = freezeOrientationOptions(measure.kind, workingPoints.map((p) => [...p]), workingOptions);
|
|
2168
|
+
if (patch) workingOptions = {
|
|
2169
|
+
...workingOptions ?? {},
|
|
2170
|
+
...patch
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
1766
2174
|
* Emit a completed gesture to every registered listener. The pre-gesture
|
|
1767
2175
|
* snapshot is provided as `previousRendered`; `commitGesture` has already
|
|
1768
2176
|
* mutated `workingPoints` and re-rendered preview / handle layers before
|
|
@@ -1828,19 +2236,20 @@ function runEdit(opts) {
|
|
|
1828
2236
|
* queued during the drag are applied after the listener emission (so the
|
|
1829
2237
|
* post-gesture render reflects the requested mode set).
|
|
1830
2238
|
*/
|
|
1831
|
-
function commitGesture(nextPoints) {
|
|
2239
|
+
function commitGesture(nextPoints, preGestureOptions) {
|
|
1832
2240
|
if (ctx.settled) return;
|
|
1833
2241
|
const previousPoints = workingPoints.map((p) => [...p]);
|
|
1834
|
-
const previousOptions = { ...workingOptions ?? {} };
|
|
2242
|
+
const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
|
|
1835
2243
|
const previousStyle = currentStyle;
|
|
1836
2244
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
1837
|
-
const previousRendered =
|
|
2245
|
+
const previousRendered = rebuildRenderedWith(previousOptions);
|
|
1838
2246
|
replaceWorkingPoints(nextPoints);
|
|
1839
2247
|
let currentRendered;
|
|
1840
2248
|
try {
|
|
1841
2249
|
currentRendered = rebuildRendered();
|
|
1842
2250
|
} catch (err) {
|
|
1843
2251
|
replaceWorkingPoints(previousPoints);
|
|
2252
|
+
if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
|
|
1844
2253
|
dragInFlight = false;
|
|
1845
2254
|
drainPendingModes();
|
|
1846
2255
|
throw err;
|
|
@@ -1930,16 +2339,23 @@ function runEdit(opts) {
|
|
|
1930
2339
|
}));
|
|
1931
2340
|
}
|
|
1932
2341
|
/**
|
|
1933
|
-
*
|
|
1934
|
-
* around the
|
|
2342
|
+
* Test seam for a rotate gesture: rotate every working point by `angle`
|
|
2343
|
+
* (radians) around the transform box's center — falling back to the
|
|
2344
|
+
* control-points' bounding-box center when no box has been fitted yet (e.g.
|
|
2345
|
+
* before the first paint). The rotation runs in screen space (see
|
|
1935
2346
|
* `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
|
|
1936
|
-
* latitude
|
|
2347
|
+
* latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
|
|
2348
|
+
* would (ADR-0017), so the box stays glued to the shape across seam calls.
|
|
1937
2349
|
*/
|
|
1938
2350
|
function commitRotate(angle) {
|
|
1939
2351
|
if (ctx.settled) return;
|
|
1940
|
-
const
|
|
2352
|
+
const preFreezeOptions = { ...workingOptions ?? {} };
|
|
2353
|
+
freezeOrientationForRotate();
|
|
2354
|
+
const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
|
|
2355
|
+
const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
|
|
1941
2356
|
if (!pivot) return;
|
|
1942
|
-
|
|
2357
|
+
boxAngle += angle;
|
|
2358
|
+
commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
|
|
1943
2359
|
}
|
|
1944
2360
|
function modesEqual(a, b) {
|
|
1945
2361
|
if (a.length !== b.length) return false;
|
|
@@ -1978,8 +2394,7 @@ function runEdit(opts) {
|
|
|
1978
2394
|
return sizeAnchor;
|
|
1979
2395
|
},
|
|
1980
2396
|
close() {
|
|
1981
|
-
|
|
1982
|
-
ctx.commit(rebuildRendered);
|
|
2397
|
+
return ctx.commit(rebuildRendered);
|
|
1983
2398
|
},
|
|
1984
2399
|
abort() {
|
|
1985
2400
|
if (ctx.settled) return;
|
|
@@ -2010,6 +2425,13 @@ function runEdit(opts) {
|
|
|
2010
2425
|
changeListeners.delete(listener);
|
|
2011
2426
|
};
|
|
2012
2427
|
},
|
|
2428
|
+
onCommit(listener) {
|
|
2429
|
+
if (ctx.settled) return () => {};
|
|
2430
|
+
return commitEmitter.register(listener);
|
|
2431
|
+
},
|
|
2432
|
+
onSettled(handler) {
|
|
2433
|
+
return settleEmitter.register(handler);
|
|
2434
|
+
},
|
|
2013
2435
|
[COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
|
|
2014
2436
|
commitGesture(nextPoints);
|
|
2015
2437
|
},
|
|
@@ -2031,13 +2453,14 @@ function runEdit(opts) {
|
|
|
2031
2453
|
};
|
|
2032
2454
|
let activeDrag = null;
|
|
2033
2455
|
/**
|
|
2034
|
-
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
2456
|
+
* A live transform-box gesture (corner scale, box rotate, or body
|
|
2457
|
+
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
2458
|
+
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
2459
|
+
* `lastSnapshot.box` instead of re-fitting while this is non-null, and
|
|
2460
|
+
* `preDragBox` lets a committed rotate compute the angle delta to fold into
|
|
2461
|
+
* `boxAngle`.
|
|
2039
2462
|
*/
|
|
2040
|
-
let
|
|
2463
|
+
let activeBoxDrag = null;
|
|
2041
2464
|
/**
|
|
2042
2465
|
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2043
2466
|
* exclusive `delete` mode). Deletion is
|
|
@@ -2076,73 +2499,149 @@ function runEdit(opts) {
|
|
|
2076
2499
|
}
|
|
2077
2500
|
const onDriverDown = (pixel, info) => {
|
|
2078
2501
|
if (ctx.settled || dragInFlight) return;
|
|
2079
|
-
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
2502
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
|
|
2080
2503
|
const hit = pickHandle(buildHandleSet(workingPoints, modes, {
|
|
2081
2504
|
includeMidpoints: midpointHandlesEnabled,
|
|
2082
|
-
trailingFixedSlots
|
|
2505
|
+
trailingFixedSlots,
|
|
2506
|
+
closedRing
|
|
2083
2507
|
}), pixel, tol, (lonLat) => driver.toPixel(lonLat));
|
|
2084
|
-
if (
|
|
2085
|
-
|
|
2086
|
-
|
|
2508
|
+
if (hit) {
|
|
2509
|
+
if (modes.includes("delete")) {
|
|
2510
|
+
if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
|
|
2511
|
+
deleteVertex(hit.index);
|
|
2512
|
+
suppressClickAway = true;
|
|
2513
|
+
}
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
2087
2517
|
deleteVertex(hit.index);
|
|
2088
2518
|
suppressClickAway = true;
|
|
2519
|
+
return;
|
|
2089
2520
|
}
|
|
2521
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2522
|
+
const { drag: fsm, initialPoints } = beginHandleDrag({
|
|
2523
|
+
hit,
|
|
2524
|
+
downPixel: pixel,
|
|
2525
|
+
points: preDragPoints,
|
|
2526
|
+
projection: dragProjection,
|
|
2527
|
+
rule
|
|
2528
|
+
});
|
|
2529
|
+
activeDrag = {
|
|
2530
|
+
fsm,
|
|
2531
|
+
preDragPoints
|
|
2532
|
+
};
|
|
2533
|
+
if (fsm.kind === "midpoint") {
|
|
2534
|
+
workingPoints.splice(0, workingPoints.length, ...initialPoints);
|
|
2535
|
+
renderPreview();
|
|
2536
|
+
renderHandlesLayer();
|
|
2537
|
+
}
|
|
2538
|
+
dragInFlight = true;
|
|
2539
|
+
driver.setDragPanEnabled(false);
|
|
2090
2540
|
return;
|
|
2091
2541
|
}
|
|
2092
|
-
if (
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2542
|
+
if (modes.includes("transform") && box) {
|
|
2543
|
+
const boxHit = pickBoxHandle(box, pixel, tol);
|
|
2544
|
+
if (boxHit) {
|
|
2545
|
+
const preDragOptions = { ...workingOptions ?? {} };
|
|
2546
|
+
if (boxHit.kind === "rotate") {
|
|
2547
|
+
freezeOrientationForRotate();
|
|
2548
|
+
renderPreview();
|
|
2549
|
+
renderHandlesLayer();
|
|
2550
|
+
}
|
|
2551
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2552
|
+
activeBoxDrag = {
|
|
2553
|
+
fsm: beginBoxDrag({
|
|
2554
|
+
kind: boxHit.kind,
|
|
2555
|
+
downPixel: pixel,
|
|
2556
|
+
points: preDragPoints,
|
|
2557
|
+
projection: dragProjection,
|
|
2558
|
+
box,
|
|
2559
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
2560
|
+
}),
|
|
2561
|
+
preDragPoints,
|
|
2562
|
+
preDragBox: box,
|
|
2563
|
+
downPixel: pixel,
|
|
2564
|
+
lastSnapshot: {
|
|
2565
|
+
points: preDragPoints,
|
|
2566
|
+
box
|
|
2567
|
+
},
|
|
2568
|
+
preDragOptions
|
|
2569
|
+
};
|
|
2570
|
+
dragInFlight = true;
|
|
2571
|
+
driver.setDragPanEnabled(false);
|
|
2572
|
+
}
|
|
2113
2573
|
}
|
|
2114
|
-
dragInFlight = true;
|
|
2115
|
-
driver.setDragPanEnabled(false);
|
|
2116
2574
|
};
|
|
2117
2575
|
function paintDragSnapshot(pixel) {
|
|
2118
2576
|
if (!activeDrag) return;
|
|
2119
2577
|
const snapshot = activeDrag.fsm.move(pixel);
|
|
2120
2578
|
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2121
|
-
|
|
2579
|
+
renderPreview();
|
|
2580
|
+
renderHandlesLayer();
|
|
2581
|
+
recomputeDirty();
|
|
2582
|
+
}
|
|
2583
|
+
function paintBoxDragSnapshot(pixel) {
|
|
2584
|
+
if (!activeBoxDrag) return;
|
|
2585
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
2586
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
2587
|
+
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2122
2588
|
renderPreview();
|
|
2123
2589
|
renderHandlesLayer();
|
|
2124
2590
|
recomputeDirty();
|
|
2125
2591
|
}
|
|
2126
2592
|
const onDriverMove = (pixel) => {
|
|
2127
|
-
if (ctx.settled
|
|
2128
|
-
paintDragSnapshot(pixel);
|
|
2593
|
+
if (ctx.settled) return;
|
|
2594
|
+
if (activeDrag) paintDragSnapshot(pixel);
|
|
2595
|
+
else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
2129
2596
|
};
|
|
2597
|
+
/** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
|
|
2598
|
+
function endDragBookkeeping() {
|
|
2599
|
+
driver.setDragPanEnabled(true);
|
|
2600
|
+
dragInFlight = false;
|
|
2601
|
+
drainPendingModes();
|
|
2602
|
+
}
|
|
2130
2603
|
const onDriverUp = (pixel) => {
|
|
2131
|
-
if (
|
|
2132
|
-
|
|
2604
|
+
if (activeDrag) {
|
|
2605
|
+
if (ctx.settled) {
|
|
2606
|
+
activeDrag = null;
|
|
2607
|
+
dragInFlight = false;
|
|
2608
|
+
driver.setDragPanEnabled(true);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const { fsm, preDragPoints } = activeDrag;
|
|
2133
2612
|
activeDrag = null;
|
|
2134
|
-
rotateHandleOverride = null;
|
|
2135
|
-
dragInFlight = false;
|
|
2136
2613
|
driver.setDragPanEnabled(true);
|
|
2614
|
+
const next = fsm.end(pixel);
|
|
2615
|
+
replaceWorkingPoints(preDragPoints);
|
|
2616
|
+
commitGesture(next);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
if (activeBoxDrag) {
|
|
2620
|
+
if (ctx.settled) {
|
|
2621
|
+
activeBoxDrag = null;
|
|
2622
|
+
dragInFlight = false;
|
|
2623
|
+
driver.setDragPanEnabled(true);
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
|
|
2627
|
+
activeBoxDrag = null;
|
|
2628
|
+
driver.setDragPanEnabled(true);
|
|
2629
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
2630
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
|
|
2631
|
+
replaceWorkingPoints(preDragPoints);
|
|
2632
|
+
renderPreview();
|
|
2633
|
+
renderHandlesLayer();
|
|
2634
|
+
recomputeDirty();
|
|
2635
|
+
endDragBookkeeping();
|
|
2636
|
+
suppressClickAway = true;
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const finalSnapshot = fsm.move(pixel);
|
|
2640
|
+
replaceWorkingPoints(preDragPoints);
|
|
2641
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
2642
|
+
commitGesture(finalSnapshot.points, preDragOptions);
|
|
2137
2643
|
return;
|
|
2138
2644
|
}
|
|
2139
|
-
const { fsm, preDragPoints } = activeDrag;
|
|
2140
|
-
activeDrag = null;
|
|
2141
|
-
rotateHandleOverride = null;
|
|
2142
|
-
driver.setDragPanEnabled(true);
|
|
2143
|
-
const next = fsm.end(pixel);
|
|
2144
|
-
replaceWorkingPoints(preDragPoints);
|
|
2145
|
-
commitGesture(next);
|
|
2146
2645
|
};
|
|
2147
2646
|
let pickPreview;
|
|
2148
2647
|
let pickHandles;
|
|
@@ -2186,7 +2685,10 @@ function runEdit(opts) {
|
|
|
2186
2685
|
handlesLayer
|
|
2187
2686
|
],
|
|
2188
2687
|
onViewChange: rescaleOnViewChange,
|
|
2189
|
-
onSettled
|
|
2688
|
+
onSettled: (settlement) => {
|
|
2689
|
+
if (onSettled) onSettled();
|
|
2690
|
+
settleFromInteraction(settlement, commitEmitter, settleEmitter);
|
|
2691
|
+
},
|
|
2190
2692
|
attach(c) {
|
|
2191
2693
|
ctx = c;
|
|
2192
2694
|
driver = adapter.createEditPointerDriver();
|
|
@@ -2206,10 +2708,10 @@ function runEdit(opts) {
|
|
|
2206
2708
|
unsubDown();
|
|
2207
2709
|
unsubMove();
|
|
2208
2710
|
unsubUp();
|
|
2209
|
-
if (activeDrag !== null) {
|
|
2711
|
+
if (activeDrag !== null || activeBoxDrag !== null) {
|
|
2210
2712
|
driver.setDragPanEnabled(true);
|
|
2211
2713
|
activeDrag = null;
|
|
2212
|
-
|
|
2714
|
+
activeBoxDrag = null;
|
|
2213
2715
|
dragInFlight = false;
|
|
2214
2716
|
}
|
|
2215
2717
|
driver.dispose();
|
|
@@ -2237,12 +2739,17 @@ function runEdit(opts) {
|
|
|
2237
2739
|
}
|
|
2238
2740
|
};
|
|
2239
2741
|
}
|
|
2742
|
+
/**
|
|
2743
|
+
* Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
|
|
2744
|
+
* throws `TypeError` synchronously for an unknown mode, collapses `delete` to
|
|
2745
|
+
* an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
|
|
2746
|
+
* Exported so the façade can hoist this validation *before* `guardAndPreempt`
|
|
2747
|
+
* — an invalid mode must not tear down whatever edit/draw is already active
|
|
2748
|
+
* (see the `td.edit` regression test for issue tracked alongside ADR-0017).
|
|
2749
|
+
*/
|
|
2240
2750
|
function normaliseModes(modes) {
|
|
2241
|
-
if (!modes || modes.length === 0) return ["reshape"];
|
|
2242
|
-
for (const m of modes) {
|
|
2243
|
-
if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2244
|
-
if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2245
|
-
}
|
|
2751
|
+
if (!modes || modes.length === 0) return ["reshape", "transform"];
|
|
2752
|
+
for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2246
2753
|
if (modes.includes("delete")) return ["delete"];
|
|
2247
2754
|
const seen = /* @__PURE__ */ new Set();
|
|
2248
2755
|
const out = [];
|
|
@@ -2270,6 +2777,641 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
|
2270
2777
|
return true;
|
|
2271
2778
|
}
|
|
2272
2779
|
//#endregion
|
|
2780
|
+
//#region src/tactical-draw/transform-controller.ts
|
|
2781
|
+
/**
|
|
2782
|
+
* Coordinate-space tolerance for `TransformSession.dirty` — mirrors
|
|
2783
|
+
* `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
|
|
2784
|
+
* than a shared import since the two controllers' dirty definitions may
|
|
2785
|
+
* diverge later: a group's dirty is "any member moved").
|
|
2786
|
+
*/
|
|
2787
|
+
const DIRTY_EPSILON = 1e-9;
|
|
2788
|
+
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
2789
|
+
/**
|
|
2790
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
2791
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
2792
|
+
* click, not a gesture — same rationale as `edit-controller.ts`'s constant of
|
|
2793
|
+
* the same name.
|
|
2794
|
+
*/
|
|
2795
|
+
const CLICK_SLOP_PX = 3;
|
|
2796
|
+
/**
|
|
2797
|
+
* Drive an interactive group transform against a `MapAdapter` — the leaner,
|
|
2798
|
+
* multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
|
|
2799
|
+
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
2800
|
+
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
2801
|
+
* grip, drag-anywhere body translate), applied rigidly to every member's
|
|
2802
|
+
* control points at once.
|
|
2803
|
+
*
|
|
2804
|
+
* - lift every member off the graphics layer + per-member working copies
|
|
2805
|
+
* - render every member's preview to the shared preview layer each paint
|
|
2806
|
+
* - group box = fit over the union of every member's rendered pixel
|
|
2807
|
+
* coordinates at the session `boxAngle`; each member also gets its own
|
|
2808
|
+
* thin outline (its own fit at the same angle)
|
|
2809
|
+
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
2810
|
+
* contracts as `runEdit`, scoped to the group
|
|
2811
|
+
* - membership is mutable via `session.setMembers` (#187): members join/leave
|
|
2812
|
+
* a live session without a session boundary
|
|
2813
|
+
*/
|
|
2814
|
+
function runGroupTransform(opts) {
|
|
2815
|
+
const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
2816
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2817
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2818
|
+
/** Build a fresh working record for `measure`, snapshotting its edit-start points. */
|
|
2819
|
+
function createMemberState(measure) {
|
|
2820
|
+
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2821
|
+
return {
|
|
2822
|
+
measure,
|
|
2823
|
+
editStartPoints,
|
|
2824
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
2825
|
+
optionsOverride: null,
|
|
2826
|
+
box: null,
|
|
2827
|
+
lastPreviewFeatures: []
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
/** The single mutable source of truth for the current member set. */
|
|
2831
|
+
let members = measures.map(createMemberState);
|
|
2832
|
+
/**
|
|
2833
|
+
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
2834
|
+
* mid-session `setMembers` add). Gates the abort-path restore so a session
|
|
2835
|
+
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
2836
|
+
*/
|
|
2837
|
+
let anyLiftedFromGraphics = false;
|
|
2838
|
+
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
2839
|
+
function mergeOptions(base, override) {
|
|
2840
|
+
if (!override) return base;
|
|
2841
|
+
return {
|
|
2842
|
+
...base ?? {},
|
|
2843
|
+
...override
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
/** Effective options for member `i`: its own options merged with its *live* override. */
|
|
2847
|
+
function effectiveOptions(i) {
|
|
2848
|
+
const member = members[i];
|
|
2849
|
+
return mergeOptions(member.measure.options, member.optionsOverride);
|
|
2850
|
+
}
|
|
2851
|
+
/** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
|
|
2852
|
+
function buildMemberSnapshot(member, override) {
|
|
2853
|
+
const working = {
|
|
2854
|
+
...member.measure,
|
|
2855
|
+
options: mergeOptions(member.measure.options, override),
|
|
2856
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2857
|
+
};
|
|
2858
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
2859
|
+
return {
|
|
2860
|
+
measure: cloneControlMeasure(committed),
|
|
2861
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
/** The working measure for member `i`: original measure, live effective options, live working points. */
|
|
2865
|
+
function workingMeasureFor(i) {
|
|
2866
|
+
const member = members[i];
|
|
2867
|
+
return {
|
|
2868
|
+
...member.measure,
|
|
2869
|
+
options: effectiveOptions(i),
|
|
2870
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
let dirty = false;
|
|
2874
|
+
/**
|
|
2875
|
+
* Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
|
|
2876
|
+
* even when every control point still matches edit-start — mirrors
|
|
2877
|
+
* `edit-controller.ts`'s flag of the same name.
|
|
2878
|
+
*/
|
|
2879
|
+
let nonGeometryDirty = false;
|
|
2880
|
+
let dragInFlight = false;
|
|
2881
|
+
/** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
|
|
2882
|
+
let suppressClickAway = false;
|
|
2883
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
2884
|
+
const commitEmitter = createCommitEmitter("TransformSession.onCommit listener errors");
|
|
2885
|
+
const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
|
|
2886
|
+
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
2887
|
+
let boxAngle = 0;
|
|
2888
|
+
/** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
|
|
2889
|
+
let groupBox = null;
|
|
2890
|
+
let ctx;
|
|
2891
|
+
let driver;
|
|
2892
|
+
const dragProjection = {
|
|
2893
|
+
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2894
|
+
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2895
|
+
};
|
|
2896
|
+
/**
|
|
2897
|
+
* Lift `member` off the graphics layer when the host reports it currently
|
|
2898
|
+
* there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
|
|
2899
|
+
* Used for both initial and mid-session-added members.
|
|
2900
|
+
*/
|
|
2901
|
+
function liftMemberFromGraphics(member) {
|
|
2902
|
+
if (!isOnGraphicsLayer(member.measure.id)) return;
|
|
2903
|
+
const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
2904
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
2905
|
+
anyLiftedFromGraphics = true;
|
|
2906
|
+
}
|
|
2907
|
+
function pixelsFor(features) {
|
|
2908
|
+
const positions = [];
|
|
2909
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
2910
|
+
const pixels = [];
|
|
2911
|
+
for (const p of positions) {
|
|
2912
|
+
const px = driver.toPixel(p);
|
|
2913
|
+
if (px) pixels.push(px);
|
|
2914
|
+
}
|
|
2915
|
+
return pixels;
|
|
2916
|
+
}
|
|
2917
|
+
/** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
|
|
2918
|
+
function currentAngle() {
|
|
2919
|
+
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
|
|
2923
|
+
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
2924
|
+
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
2925
|
+
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
2926
|
+
* still re-fitted every paint at the drag's live angle, since they are a
|
|
2927
|
+
* derived display only (no self-fight risk).
|
|
2928
|
+
*/
|
|
2929
|
+
function refitBoxes() {
|
|
2930
|
+
const angle = currentAngle();
|
|
2931
|
+
const padding = interactionStyle?.boxPadding ?? 0;
|
|
2932
|
+
const allPixels = [];
|
|
2933
|
+
for (const member of members) {
|
|
2934
|
+
const pixels = pixelsFor(member.lastPreviewFeatures);
|
|
2935
|
+
allPixels.push(...pixels);
|
|
2936
|
+
member.box = fitTransformBox(pixels, angle, padding);
|
|
2937
|
+
}
|
|
2938
|
+
if (activeBoxDrag) return;
|
|
2939
|
+
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
2940
|
+
}
|
|
2941
|
+
function renderAllPreviews() {
|
|
2942
|
+
if (ctx.settled) return;
|
|
2943
|
+
const allFeatures = [];
|
|
2944
|
+
members.forEach((member, i) => {
|
|
2945
|
+
const working = workingMeasureFor(i);
|
|
2946
|
+
let features;
|
|
2947
|
+
try {
|
|
2948
|
+
features = renderMeasureForAdapter(adapter, working, renderOpts).features;
|
|
2949
|
+
} catch {
|
|
2950
|
+
features = [];
|
|
2951
|
+
}
|
|
2952
|
+
member.lastPreviewFeatures = features;
|
|
2953
|
+
allFeatures.push(...features);
|
|
2954
|
+
});
|
|
2955
|
+
ctx.writeLayer(previewLayer, allFeatures);
|
|
2956
|
+
refitBoxes();
|
|
2957
|
+
}
|
|
2958
|
+
function rescaleOnViewChange() {
|
|
2959
|
+
if (ctx.settled) return;
|
|
2960
|
+
if (members.some((m) => measureUsesAdapterResolution({
|
|
2961
|
+
kind: m.measure.kind,
|
|
2962
|
+
options: m.measure.options
|
|
2963
|
+
}))) renderAllPreviews();
|
|
2964
|
+
else refitBoxes();
|
|
2965
|
+
renderHandlesLayer();
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
|
|
2969
|
+
* so the group reads as dashed per the reference design — unless the host
|
|
2970
|
+
* explicitly set their own `strokeDash`, which wins.
|
|
2971
|
+
*/
|
|
2972
|
+
function groupOutlineStyle() {
|
|
2973
|
+
const base = interactionStyle?.boxOutline;
|
|
2974
|
+
if (base?.strokeDash !== void 0) return base;
|
|
2975
|
+
return {
|
|
2976
|
+
...base ?? {},
|
|
2977
|
+
strokeDash: [4, 4]
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
function renderHandlesLayer() {
|
|
2981
|
+
if (ctx.settled) return;
|
|
2982
|
+
const features = [];
|
|
2983
|
+
const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
|
|
2984
|
+
if (liveGroupBox) {
|
|
2985
|
+
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
|
|
2986
|
+
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2987
|
+
}
|
|
2988
|
+
for (const member of members) {
|
|
2989
|
+
const memberBox = member.box;
|
|
2990
|
+
if (!memberBox) continue;
|
|
2991
|
+
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
|
|
2992
|
+
}
|
|
2993
|
+
ctx.writeLayer(handlesLayer, features);
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Builds every member's committed snapshot against a given override array
|
|
2997
|
+
* rather than the live per-member override — used to render a gesture's
|
|
2998
|
+
* pre-freeze "previous" snapshot when a rotate has already mutated the live
|
|
2999
|
+
* overrides by the time the commit runs. Index-aligned with `members`, which
|
|
3000
|
+
* is sound because membership cannot change within a gesture emission.
|
|
3001
|
+
*/
|
|
3002
|
+
function rebuildRenderedAllWith(overrides) {
|
|
3003
|
+
return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
|
|
3004
|
+
}
|
|
3005
|
+
function rebuildRenderedAll() {
|
|
3006
|
+
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
3007
|
+
}
|
|
3008
|
+
function recomputeDirty() {
|
|
3009
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
|
|
3010
|
+
}
|
|
3011
|
+
/** Split `points` (the concatenation of every member's control points) back per member. */
|
|
3012
|
+
function applyConcatenatedPoints(points) {
|
|
3013
|
+
const rounded = roundControlPoints(points);
|
|
3014
|
+
let offset = 0;
|
|
3015
|
+
for (const member of members) {
|
|
3016
|
+
const c = member.workingPoints.length;
|
|
3017
|
+
const slice = rounded.slice(offset, offset + c);
|
|
3018
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...slice);
|
|
3019
|
+
offset += c;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
function concatWorkingPoints() {
|
|
3023
|
+
const out = [];
|
|
3024
|
+
for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
|
|
3025
|
+
return out;
|
|
3026
|
+
}
|
|
3027
|
+
function clonePointsByMember() {
|
|
3028
|
+
return members.map((m) => m.workingPoints.map((p) => [...p]));
|
|
3029
|
+
}
|
|
3030
|
+
function restorePointsByMember(snapshot) {
|
|
3031
|
+
members.forEach((member, i) => {
|
|
3032
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
function cloneOptionsOverrideByMember() {
|
|
3036
|
+
return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
|
|
3037
|
+
}
|
|
3038
|
+
/** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
|
|
3039
|
+
function applyOptionsOverrides(overrides) {
|
|
3040
|
+
members.forEach((member, i) => {
|
|
3041
|
+
const o = overrides[i];
|
|
3042
|
+
member.optionsOverride = o ? { ...o } : null;
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
3047
|
+
* anchor) for every member, the moment a group rotate gesture starts —
|
|
3048
|
+
* mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
|
|
3049
|
+
* per-member since the group has no single working-options set.
|
|
3050
|
+
*/
|
|
3051
|
+
function freezeOrientationForRotate() {
|
|
3052
|
+
members.forEach((member, i) => {
|
|
3053
|
+
const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
3054
|
+
if (patch) member.optionsOverride = {
|
|
3055
|
+
...member.optionsOverride ?? {},
|
|
3056
|
+
...patch
|
|
3057
|
+
};
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Emit a completed gesture to every registered listener — the group mirror
|
|
3062
|
+
* of `edit-controller.ts`'s `emitChange` (issue #49 contract): same
|
|
3063
|
+
* instance to all listeners in order, deferred `reject`/`close`/`abort`
|
|
3064
|
+
* (close/abort override reject), exception collection with `AggregateError`.
|
|
3065
|
+
*/
|
|
3066
|
+
function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
|
|
3067
|
+
if (ctx.settled) return;
|
|
3068
|
+
let rejectRequested = false;
|
|
3069
|
+
let closeRequested = false;
|
|
3070
|
+
let abortRequested = false;
|
|
3071
|
+
const event = {
|
|
3072
|
+
measures: currentRendered,
|
|
3073
|
+
previous: previousRendered,
|
|
3074
|
+
session,
|
|
3075
|
+
reject() {
|
|
3076
|
+
rejectRequested = true;
|
|
3077
|
+
},
|
|
3078
|
+
close() {
|
|
3079
|
+
closeRequested = true;
|
|
3080
|
+
},
|
|
3081
|
+
abort() {
|
|
3082
|
+
abortRequested = true;
|
|
3083
|
+
}
|
|
3084
|
+
};
|
|
3085
|
+
const membersAtEmission = members;
|
|
3086
|
+
const errors = [];
|
|
3087
|
+
for (const listener of [...changeListeners]) try {
|
|
3088
|
+
listener(event);
|
|
3089
|
+
} catch (err) {
|
|
3090
|
+
errors.push(err);
|
|
3091
|
+
}
|
|
3092
|
+
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
3093
|
+
else if (closeRequested) commitClose();
|
|
3094
|
+
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
3095
|
+
restorePointsByMember(previousPointsByMember);
|
|
3096
|
+
sizeAnchor = previousSizeAnchor;
|
|
3097
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
3098
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
3099
|
+
renderAllPreviews();
|
|
3100
|
+
renderHandlesLayer();
|
|
3101
|
+
recomputeDirty();
|
|
3102
|
+
}
|
|
3103
|
+
if (errors.length === 1) throw errors[0];
|
|
3104
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
|
|
3105
|
+
}
|
|
3106
|
+
/**
|
|
3107
|
+
* Commit via `ctx.commit` and emit the close batch to `onCommit` listeners,
|
|
3108
|
+
* then emit the settle to `onSettled` listeners (#194) — in a try/finally so
|
|
3109
|
+
* a throwing `onCommit` listener cannot starve the settle emission. The
|
|
3110
|
+
* emission happens after the interaction core has settled (teardown and
|
|
3111
|
+
* `onSettled` already ran inside `ctx.commit`), so a listener that calls
|
|
3112
|
+
* `td.render()` sees these members no longer excluded from reconcile — the
|
|
3113
|
+
* fold-then-render pattern works from inside the handler. `undefined` covers
|
|
3114
|
+
* both an already-settled session and a `produce` throw (the promise
|
|
3115
|
+
* rejects); neither emits here — a `produce` throw already emitted the
|
|
3116
|
+
* settle with reason `"error"` via the wrapped `onSettled` passed to
|
|
3117
|
+
* `runInteraction`.
|
|
3118
|
+
*/
|
|
3119
|
+
function commitClose() {
|
|
3120
|
+
const snapshots = ctx.commit(rebuildRenderedAll);
|
|
3121
|
+
if (snapshots !== void 0) try {
|
|
3122
|
+
commitEmitter.emit(snapshots);
|
|
3123
|
+
} finally {
|
|
3124
|
+
settleEmitter.emit("close");
|
|
3125
|
+
}
|
|
3126
|
+
return snapshots;
|
|
3127
|
+
}
|
|
3128
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
|
|
3129
|
+
if (ctx.settled) return;
|
|
3130
|
+
const previousPointsByMember = clonePointsByMember();
|
|
3131
|
+
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
3132
|
+
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
3133
|
+
applyConcatenatedPoints(nextConcatPoints);
|
|
3134
|
+
let currentRendered;
|
|
3135
|
+
try {
|
|
3136
|
+
currentRendered = rebuildRenderedAll();
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
restorePointsByMember(previousPointsByMember);
|
|
3139
|
+
dragInFlight = false;
|
|
3140
|
+
throw err;
|
|
3141
|
+
}
|
|
3142
|
+
renderAllPreviews();
|
|
3143
|
+
renderHandlesLayer();
|
|
3144
|
+
recomputeDirty();
|
|
3145
|
+
dragInFlight = false;
|
|
3146
|
+
emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3147
|
+
}
|
|
3148
|
+
function setSizeAnchor(next) {
|
|
3149
|
+
if (ctx.settled || sizeAnchor === next) return;
|
|
3150
|
+
const previousPointsByMember = clonePointsByMember();
|
|
3151
|
+
const previousSizeAnchor = sizeAnchor;
|
|
3152
|
+
const previousNonGeometryDirty = nonGeometryDirty;
|
|
3153
|
+
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3154
|
+
const previousRendered = rebuildRenderedAll();
|
|
3155
|
+
sizeAnchor = next;
|
|
3156
|
+
const currentRendered = rebuildRenderedAll();
|
|
3157
|
+
nonGeometryDirty = true;
|
|
3158
|
+
dirty = true;
|
|
3159
|
+
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
3163
|
+
* geometry and option overrides, re-enable drag pan, and clear the drag. Used
|
|
3164
|
+
* by `setMembers` so a membership change never bakes a half-finished drag; the
|
|
3165
|
+
* caller is responsible for the follow-up repaint (`renderAllPreviews` /
|
|
3166
|
+
* `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
|
|
3167
|
+
* rather than rendering the outgoing member set only to redo it immediately.
|
|
3168
|
+
*/
|
|
3169
|
+
function cancelActiveBoxDrag() {
|
|
3170
|
+
if (!activeBoxDrag) return;
|
|
3171
|
+
const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3172
|
+
activeBoxDrag = null;
|
|
3173
|
+
driver.setDragPanEnabled(true);
|
|
3174
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3175
|
+
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
3176
|
+
dragInFlight = false;
|
|
3177
|
+
}
|
|
3178
|
+
function setMembers(next) {
|
|
3179
|
+
if (ctx.settled) return void 0;
|
|
3180
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3181
|
+
for (const m of next) {
|
|
3182
|
+
if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
|
|
3183
|
+
seen.add(m.id);
|
|
3184
|
+
}
|
|
3185
|
+
if (next.length === 0) return close();
|
|
3186
|
+
cancelActiveBoxDrag();
|
|
3187
|
+
const nextIds = new Set(next.map((m) => m.id));
|
|
3188
|
+
const currentById = new Map(members.map((m) => [m.measure.id, m]));
|
|
3189
|
+
const removedSnapshots = [];
|
|
3190
|
+
for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
3191
|
+
const nextMembers = [];
|
|
3192
|
+
for (const measure of next) {
|
|
3193
|
+
const existing = currentById.get(measure.id);
|
|
3194
|
+
if (existing) nextMembers.push(existing);
|
|
3195
|
+
else {
|
|
3196
|
+
const member = createMemberState(measure);
|
|
3197
|
+
liftMemberFromGraphics(member);
|
|
3198
|
+
nextMembers.push(member);
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
members = nextMembers;
|
|
3202
|
+
renderAllPreviews();
|
|
3203
|
+
renderHandlesLayer();
|
|
3204
|
+
recomputeDirty();
|
|
3205
|
+
if (removedSnapshots.length > 0) commitEmitter.emit(removedSnapshots);
|
|
3206
|
+
return removedSnapshots;
|
|
3207
|
+
}
|
|
3208
|
+
function close() {
|
|
3209
|
+
return commitClose();
|
|
3210
|
+
}
|
|
3211
|
+
const session = {
|
|
3212
|
+
get measures() {
|
|
3213
|
+
return members.map((m) => m.measure);
|
|
3214
|
+
},
|
|
3215
|
+
controlPointsFor(measureId) {
|
|
3216
|
+
const member = members.find((m) => m.measure.id === measureId);
|
|
3217
|
+
if (!member) return void 0;
|
|
3218
|
+
return member.workingPoints.map((p) => [...p]);
|
|
3219
|
+
},
|
|
3220
|
+
get dirty() {
|
|
3221
|
+
return dirty;
|
|
3222
|
+
},
|
|
3223
|
+
get sizeAnchor() {
|
|
3224
|
+
return sizeAnchor;
|
|
3225
|
+
},
|
|
3226
|
+
setSizeAnchor(next) {
|
|
3227
|
+
setSizeAnchor(next);
|
|
3228
|
+
},
|
|
3229
|
+
setMembers,
|
|
3230
|
+
close,
|
|
3231
|
+
abort() {
|
|
3232
|
+
if (ctx.settled) return;
|
|
3233
|
+
ctx.fail(new TacticalDrawAbortError("session"));
|
|
3234
|
+
},
|
|
3235
|
+
onChange(listener) {
|
|
3236
|
+
if (ctx.settled) return () => {};
|
|
3237
|
+
changeListeners.add(listener);
|
|
3238
|
+
return () => {
|
|
3239
|
+
changeListeners.delete(listener);
|
|
3240
|
+
};
|
|
3241
|
+
},
|
|
3242
|
+
onCommit(listener) {
|
|
3243
|
+
if (ctx.settled) return () => {};
|
|
3244
|
+
return commitEmitter.register(listener);
|
|
3245
|
+
},
|
|
3246
|
+
onSettled(handler) {
|
|
3247
|
+
return settleEmitter.register(handler);
|
|
3248
|
+
}
|
|
3249
|
+
};
|
|
3250
|
+
let activeBoxDrag = null;
|
|
3251
|
+
const onDriverDown = (pixel, info) => {
|
|
3252
|
+
if (ctx.settled || dragInFlight) return;
|
|
3253
|
+
if (!groupBox) return;
|
|
3254
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
3255
|
+
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
3256
|
+
if (!boxHit) return;
|
|
3257
|
+
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3258
|
+
if (boxHit.kind === "rotate") {
|
|
3259
|
+
freezeOrientationForRotate();
|
|
3260
|
+
renderAllPreviews();
|
|
3261
|
+
renderHandlesLayer();
|
|
3262
|
+
}
|
|
3263
|
+
const preDragPoints = concatWorkingPoints();
|
|
3264
|
+
activeBoxDrag = {
|
|
3265
|
+
fsm: beginBoxDrag({
|
|
3266
|
+
kind: boxHit.kind,
|
|
3267
|
+
downPixel: pixel,
|
|
3268
|
+
points: preDragPoints,
|
|
3269
|
+
projection: dragProjection,
|
|
3270
|
+
box: groupBox,
|
|
3271
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
3272
|
+
}),
|
|
3273
|
+
preDragPoints,
|
|
3274
|
+
preDragBox: groupBox,
|
|
3275
|
+
downPixel: pixel,
|
|
3276
|
+
lastSnapshot: {
|
|
3277
|
+
points: preDragPoints,
|
|
3278
|
+
box: groupBox
|
|
3279
|
+
},
|
|
3280
|
+
preDragOptionsOverrideByMember
|
|
3281
|
+
};
|
|
3282
|
+
dragInFlight = true;
|
|
3283
|
+
driver.setDragPanEnabled(false);
|
|
3284
|
+
};
|
|
3285
|
+
function paintBoxDragSnapshot(pixel) {
|
|
3286
|
+
if (!activeBoxDrag) return;
|
|
3287
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
3288
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
3289
|
+
applyConcatenatedPoints(snapshot.points);
|
|
3290
|
+
renderAllPreviews();
|
|
3291
|
+
renderHandlesLayer();
|
|
3292
|
+
recomputeDirty();
|
|
3293
|
+
}
|
|
3294
|
+
const onDriverMove = (pixel) => {
|
|
3295
|
+
if (ctx.settled) return;
|
|
3296
|
+
if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
3297
|
+
};
|
|
3298
|
+
const onDriverUp = (pixel) => {
|
|
3299
|
+
if (!activeBoxDrag) return;
|
|
3300
|
+
if (ctx.settled) {
|
|
3301
|
+
activeBoxDrag = null;
|
|
3302
|
+
dragInFlight = false;
|
|
3303
|
+
driver.setDragPanEnabled(true);
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3307
|
+
activeBoxDrag = null;
|
|
3308
|
+
driver.setDragPanEnabled(true);
|
|
3309
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
3310
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
|
|
3311
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3312
|
+
renderAllPreviews();
|
|
3313
|
+
renderHandlesLayer();
|
|
3314
|
+
recomputeDirty();
|
|
3315
|
+
dragInFlight = false;
|
|
3316
|
+
suppressClickAway = true;
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
const finalSnapshot = fsm.move(pixel);
|
|
3320
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3321
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
3322
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
|
|
3323
|
+
};
|
|
3324
|
+
let pickPreview;
|
|
3325
|
+
let pickHandles;
|
|
3326
|
+
let pickGraphics;
|
|
3327
|
+
const handlePickPreview = (e) => {
|
|
3328
|
+
pickPreview = e;
|
|
3329
|
+
};
|
|
3330
|
+
const handlePickHandles = (e) => {
|
|
3331
|
+
pickHandles = e;
|
|
3332
|
+
};
|
|
3333
|
+
const handlePickGraphics = (e) => {
|
|
3334
|
+
pickGraphics = e;
|
|
3335
|
+
};
|
|
3336
|
+
const handleClick = () => {
|
|
3337
|
+
const localPreview = pickPreview;
|
|
3338
|
+
const localHandles = pickHandles;
|
|
3339
|
+
const localGraphics = pickGraphics;
|
|
3340
|
+
pickPreview = void 0;
|
|
3341
|
+
pickHandles = void 0;
|
|
3342
|
+
pickGraphics = void 0;
|
|
3343
|
+
if (ctx.settled) return;
|
|
3344
|
+
if (!closeOnClickAway) return;
|
|
3345
|
+
if (suppressClickAway) {
|
|
3346
|
+
suppressClickAway = false;
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
if (localPreview || localHandles) return;
|
|
3350
|
+
const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
|
|
3351
|
+
if (localGraphics && !isMember) {
|
|
3352
|
+
commitClose();
|
|
3353
|
+
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
commitClose();
|
|
3357
|
+
};
|
|
3358
|
+
const core = runInteraction({
|
|
3359
|
+
adapter,
|
|
3360
|
+
signal,
|
|
3361
|
+
layers: [previewLayer, handlesLayer],
|
|
3362
|
+
onViewChange: rescaleOnViewChange,
|
|
3363
|
+
onSettled: (settlement) => {
|
|
3364
|
+
if (onSettled) onSettled();
|
|
3365
|
+
if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
|
|
3366
|
+
},
|
|
3367
|
+
attach(c) {
|
|
3368
|
+
ctx = c;
|
|
3369
|
+
driver = adapter.createEditPointerDriver();
|
|
3370
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
3371
|
+
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
3372
|
+
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
3373
|
+
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
3374
|
+
const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
|
|
3375
|
+
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
3376
|
+
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
3377
|
+
adapter.on("click", handleClick);
|
|
3378
|
+
c.onTeardown(() => {
|
|
3379
|
+
adapter.off("click", handleClick);
|
|
3380
|
+
unsubPickPreview();
|
|
3381
|
+
unsubPickHandles();
|
|
3382
|
+
unsubPickGraphics();
|
|
3383
|
+
unsubDown();
|
|
3384
|
+
unsubMove();
|
|
3385
|
+
unsubUp();
|
|
3386
|
+
if (activeBoxDrag !== null) {
|
|
3387
|
+
driver.setDragPanEnabled(true);
|
|
3388
|
+
activeBoxDrag = null;
|
|
3389
|
+
dragInFlight = false;
|
|
3390
|
+
}
|
|
3391
|
+
driver.dispose();
|
|
3392
|
+
}, () => {
|
|
3393
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
3394
|
+
});
|
|
3395
|
+
renderAllPreviews();
|
|
3396
|
+
renderHandlesLayer();
|
|
3397
|
+
recomputeDirty();
|
|
3398
|
+
if (onSession) onSession(session);
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
return {
|
|
3402
|
+
promise: core.promise,
|
|
3403
|
+
get settled() {
|
|
3404
|
+
return core.settled;
|
|
3405
|
+
},
|
|
3406
|
+
get measureIds() {
|
|
3407
|
+
return members.map((m) => m.measure.id);
|
|
3408
|
+
},
|
|
3409
|
+
abort(reason, cause) {
|
|
3410
|
+
core.abort(reason, cause);
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
//#endregion
|
|
2273
3415
|
//#region src/tactical-draw/errors.ts
|
|
2274
3416
|
/**
|
|
2275
3417
|
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
@@ -2312,6 +3454,20 @@ var TacticalDraw = class {
|
|
|
2312
3454
|
*/
|
|
2313
3455
|
activeEdit = null;
|
|
2314
3456
|
/**
|
|
3457
|
+
* Tracked alongside `activeInteraction` whenever the interaction is a group
|
|
3458
|
+
* transform (`editMany`). The `render()` / view-change paths need every
|
|
3459
|
+
* member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
|
|
3460
|
+
* but for `TransformHandle`'s `measureIds` array.
|
|
3461
|
+
*/
|
|
3462
|
+
activeTransform = null;
|
|
3463
|
+
/**
|
|
3464
|
+
* The live `TransformSession`, exposed via `activeTransformSession`. Set
|
|
3465
|
+
* directly by `editMany`'s `onSession` wrapper (not routed through
|
|
3466
|
+
* `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
|
|
3467
|
+
* and cleared on settle.
|
|
3468
|
+
*/
|
|
3469
|
+
activeTransformSessionRef = null;
|
|
3470
|
+
/**
|
|
2315
3471
|
* Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
|
|
2316
3472
|
* façade subscribes once to `adapter.onPick` per layer and routes events
|
|
2317
3473
|
* through this map so that close-then-pick ordering during an active edit can
|
|
@@ -2329,6 +3485,7 @@ var TacticalDraw = class {
|
|
|
2329
3485
|
deferredGraphicsPick = null;
|
|
2330
3486
|
idCounter = 0;
|
|
2331
3487
|
editCounter = 0;
|
|
3488
|
+
transformCounter = 0;
|
|
2332
3489
|
/**
|
|
2333
3490
|
* Snapshot of the host's last `render()` argument. The edit slice uses it
|
|
2334
3491
|
* to recompute the graphics-layer state during a lift / restore — the
|
|
@@ -2380,12 +3537,35 @@ var TacticalDraw = class {
|
|
|
2380
3537
|
*/
|
|
2381
3538
|
handleViewChange = () => {
|
|
2382
3539
|
if (this.destroyed || !this.hasRendered) return;
|
|
2383
|
-
const
|
|
2384
|
-
const visible =
|
|
3540
|
+
const excluded = this.excludedInteractionIds();
|
|
3541
|
+
const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
|
|
2385
3542
|
if (!visible.some(measureUsesAdapterResolution)) return;
|
|
2386
3543
|
this.renderMeasuresToGraphics(visible);
|
|
2387
3544
|
};
|
|
2388
3545
|
/**
|
|
3546
|
+
* Every measure id currently lifted onto the preview layer by an active
|
|
3547
|
+
* edit or group transform — excluded from `render()` / view-change
|
|
3548
|
+
* reconciliation because the preview layer (not graphics) owns their
|
|
3549
|
+
* working copy. Empty while idle.
|
|
3550
|
+
*/
|
|
3551
|
+
excludedInteractionIds() {
|
|
3552
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3553
|
+
if (this.activeEdit) ids.add(this.activeEdit.measureId);
|
|
3554
|
+
for (const id of this.transformMemberIds() ?? []) ids.add(id);
|
|
3555
|
+
return ids;
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
3559
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
3560
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
3561
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
3562
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
3563
|
+
*/
|
|
3564
|
+
transformMemberIds() {
|
|
3565
|
+
if (this.activeTransform) return this.activeTransform.measureIds;
|
|
3566
|
+
return this.activeTransformSessionRef?.measures.map((m) => m.id);
|
|
3567
|
+
}
|
|
3568
|
+
/**
|
|
2389
3569
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
2390
3570
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
2391
3571
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -2424,7 +3604,10 @@ var TacticalDraw = class {
|
|
|
2424
3604
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
2425
3605
|
editedRemoved = externalEdited === void 0;
|
|
2426
3606
|
}
|
|
2427
|
-
const
|
|
3607
|
+
const transformIds = this.transformMemberIds();
|
|
3608
|
+
const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
|
|
3609
|
+
const excluded = this.excludedInteractionIds();
|
|
3610
|
+
const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
|
|
2428
3611
|
if (!(visible.length === 0 && !this.hasRendered)) {
|
|
2429
3612
|
this.renderMeasuresToGraphics(visible);
|
|
2430
3613
|
this.hasRendered = true;
|
|
@@ -2437,6 +3620,16 @@ var TacticalDraw = class {
|
|
|
2437
3620
|
editHandle.abort("removed");
|
|
2438
3621
|
});
|
|
2439
3622
|
}
|
|
3623
|
+
if (transformMemberRemoved) {
|
|
3624
|
+
const sessionRef = this.activeTransformSessionRef;
|
|
3625
|
+
queueMicrotask(() => {
|
|
3626
|
+
const handle = this.activeTransform;
|
|
3627
|
+
if (!handle || handle.settled) return;
|
|
3628
|
+
if (this.activeTransformSessionRef !== sessionRef) return;
|
|
3629
|
+
const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
|
|
3630
|
+
if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
|
|
3631
|
+
});
|
|
3632
|
+
}
|
|
2440
3633
|
}
|
|
2441
3634
|
/**
|
|
2442
3635
|
* Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
|
|
@@ -2454,6 +3647,8 @@ var TacticalDraw = class {
|
|
|
2454
3647
|
}
|
|
2455
3648
|
if (this.activeInteraction.isActive) {
|
|
2456
3649
|
this.activeEdit = null;
|
|
3650
|
+
this.activeTransform = null;
|
|
3651
|
+
this.activeTransformSessionRef = null;
|
|
2457
3652
|
this.activeInteraction.cancel("preempted");
|
|
2458
3653
|
}
|
|
2459
3654
|
return null;
|
|
@@ -2469,14 +3664,17 @@ var TacticalDraw = class {
|
|
|
2469
3664
|
* whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
|
|
2470
3665
|
* caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
|
|
2471
3666
|
*/
|
|
2472
|
-
runWithSlot(create,
|
|
3667
|
+
runWithSlot(create, activate, extraSettle) {
|
|
2473
3668
|
let handle;
|
|
2474
|
-
let
|
|
3669
|
+
let deactivate = null;
|
|
2475
3670
|
let settledDuringCreate = false;
|
|
2476
3671
|
const clearSlot = () => {
|
|
2477
3672
|
if (handle) {
|
|
2478
3673
|
this.activeInteraction.clear(handle);
|
|
2479
|
-
if (
|
|
3674
|
+
if (deactivate) {
|
|
3675
|
+
deactivate();
|
|
3676
|
+
deactivate = null;
|
|
3677
|
+
}
|
|
2480
3678
|
} else {
|
|
2481
3679
|
settledDuringCreate = true;
|
|
2482
3680
|
this.activeInteraction.clear();
|
|
@@ -2489,7 +3687,6 @@ var TacticalDraw = class {
|
|
|
2489
3687
|
this.activeInteraction.begin();
|
|
2490
3688
|
try {
|
|
2491
3689
|
handle = create(onSettled);
|
|
2492
|
-
editHandle = editHandleOf ? editHandleOf(handle) : null;
|
|
2493
3690
|
} catch (error) {
|
|
2494
3691
|
this.activeInteraction.clear();
|
|
2495
3692
|
if (!settledDuringCreate && extraSettle) extraSettle();
|
|
@@ -2497,7 +3694,7 @@ var TacticalDraw = class {
|
|
|
2497
3694
|
}
|
|
2498
3695
|
if (!settledDuringCreate) {
|
|
2499
3696
|
this.activeInteraction.set(handle);
|
|
2500
|
-
if (
|
|
3697
|
+
if (activate && !handle.settled) deactivate = activate(handle) ?? null;
|
|
2501
3698
|
}
|
|
2502
3699
|
handle.promise.then(clearSlot, clearSlot);
|
|
2503
3700
|
return handle.promise;
|
|
@@ -2513,11 +3710,29 @@ var TacticalDraw = class {
|
|
|
2513
3710
|
};
|
|
2514
3711
|
}
|
|
2515
3712
|
/**
|
|
3713
|
+
* Wrap a host `onSession` callback for `editMany`, recording the session on
|
|
3714
|
+
* `activeTransformSessionRef` (exposed via `activeTransformSession`) before
|
|
3715
|
+
* the host's own handler runs. Separate from `trackSession` because
|
|
3716
|
+
* `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
|
|
3717
|
+
*/
|
|
3718
|
+
trackTransformSession(hostOnSession) {
|
|
3719
|
+
return (session) => {
|
|
3720
|
+
this.activeTransformSessionRef = session;
|
|
3721
|
+
hostOnSession?.(session);
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
/**
|
|
2516
3725
|
* Start an interactive draw. Fixed-length kinds commit automatically on the
|
|
2517
3726
|
* last required click. Variable-length kinds surface a `DrawSession` via
|
|
2518
3727
|
* `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
|
|
2519
3728
|
* click on the last added control point. All paths reject with
|
|
2520
3729
|
* `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
|
|
3730
|
+
*
|
|
3731
|
+
* For a variable-length draw, `DrawSession.onCommit` is the single delivery
|
|
3732
|
+
* channel for the committed snapshot and `DrawSession.onSettled` is the
|
|
3733
|
+
* lifecycle signal for when the session is over and why (#197) — together
|
|
3734
|
+
* they make the returned promise non-load-bearing for hosts that adopt them.
|
|
3735
|
+
* Fixed-length kinds have no session and settle only through the promise.
|
|
2521
3736
|
*/
|
|
2522
3737
|
draw(draft, options) {
|
|
2523
3738
|
this.assertAlive();
|
|
@@ -2539,6 +3754,7 @@ var TacticalDraw = class {
|
|
|
2539
3754
|
adapter: this.adapter,
|
|
2540
3755
|
kind,
|
|
2541
3756
|
previewLayer: this.previewLayer,
|
|
3757
|
+
guideLayer: this.guideLayer,
|
|
2542
3758
|
graphicsStyle: this.graphicsStyle,
|
|
2543
3759
|
generateId: () => draft.id ?? this.generateId(),
|
|
2544
3760
|
signal: options?.signal,
|
|
@@ -2575,12 +3791,15 @@ var TacticalDraw = class {
|
|
|
2575
3791
|
* Default click-away (`closeOnClickAway: true`) closes the edit on a click
|
|
2576
3792
|
* outside the preview / handle features. Hosts that own close policy pass
|
|
2577
3793
|
* `false`.
|
|
3794
|
+
*
|
|
3795
|
+
* `EditSession.onCommit` is the single delivery channel for the committed
|
|
3796
|
+
* snapshot and `EditSession.onSettled` is the lifecycle signal for when the
|
|
3797
|
+
* session is over and why (#197) — together they make the returned promise
|
|
3798
|
+
* non-load-bearing for hosts that adopt them.
|
|
2578
3799
|
*/
|
|
2579
3800
|
edit(measure, options) {
|
|
2580
3801
|
this.assertAlive();
|
|
2581
|
-
|
|
2582
|
-
for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2583
|
-
}
|
|
3802
|
+
normaliseModes(options?.modes);
|
|
2584
3803
|
const preempt = this.guardAndPreempt();
|
|
2585
3804
|
if (preempt) return preempt;
|
|
2586
3805
|
const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
|
|
@@ -2613,7 +3832,121 @@ var TacticalDraw = class {
|
|
|
2613
3832
|
wasOnGraphicsLayer,
|
|
2614
3833
|
onSettled,
|
|
2615
3834
|
onClickAwayGraphicsPick
|
|
2616
|
-
}), (handle) =>
|
|
3835
|
+
}), (handle) => {
|
|
3836
|
+
this.activeEdit = handle;
|
|
3837
|
+
return () => {
|
|
3838
|
+
if (this.activeEdit === handle) this.activeEdit = null;
|
|
3839
|
+
};
|
|
3840
|
+
}, revertGuideStyle);
|
|
3841
|
+
}
|
|
3842
|
+
/**
|
|
3843
|
+
* Start an interactive group transform (multi-select) over `measures`
|
|
3844
|
+
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
3845
|
+
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
3846
|
+
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
3847
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
3848
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
3849
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
3850
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
3851
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
3852
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
3853
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
3854
|
+
*
|
|
3855
|
+
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
3856
|
+
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
3857
|
+
* any preemption) for an empty array or duplicate ids.
|
|
3858
|
+
*
|
|
3859
|
+
* For a selection that changes over time (shift-click toggle, marquee
|
|
3860
|
+
* replace), prefer [[syncTransformMembers]] over hand-rolling the
|
|
3861
|
+
* live-session-vs-fresh-`editMany` branch, and subscribe to
|
|
3862
|
+
* `TransformSession.onCommit` as the single fold path for exit snapshots
|
|
3863
|
+
* and `TransformSession.onSettled` as the lifecycle signal for when the
|
|
3864
|
+
* session is over and why.
|
|
3865
|
+
*/
|
|
3866
|
+
editMany(measures, options) {
|
|
3867
|
+
this.assertAlive();
|
|
3868
|
+
if (measures.length === 0) throw new TypeError("td.editMany: measures must not be empty");
|
|
3869
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3870
|
+
for (const m of measures) {
|
|
3871
|
+
if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate control measure id "${m.id}"`);
|
|
3872
|
+
seenIds.add(m.id);
|
|
3873
|
+
}
|
|
3874
|
+
const preempt = this.guardAndPreempt();
|
|
3875
|
+
if (preempt) return preempt;
|
|
3876
|
+
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
3877
|
+
const restoreOriginalToGraphics = () => {
|
|
3878
|
+
this.renderMeasuresToGraphics(this.lastRenderedMeasures);
|
|
3879
|
+
};
|
|
3880
|
+
const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
|
|
3881
|
+
const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
|
|
3882
|
+
const onClickAwayGraphicsPick = (event) => {
|
|
3883
|
+
this.dispatchHostPick(this.graphicsLayer, event);
|
|
3884
|
+
};
|
|
3885
|
+
const extraSettle = () => {
|
|
3886
|
+
this.activeTransformSessionRef = null;
|
|
3887
|
+
};
|
|
3888
|
+
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
3889
|
+
adapter: this.adapter,
|
|
3890
|
+
measures,
|
|
3891
|
+
graphicsLayer: this.graphicsLayer,
|
|
3892
|
+
previewLayer: this.previewLayer,
|
|
3893
|
+
handlesLayer: this.handlesLayer,
|
|
3894
|
+
graphicsStyle: this.graphicsStyle,
|
|
3895
|
+
signal: options?.signal,
|
|
3896
|
+
closeOnClickAway: options?.closeOnClickAway,
|
|
3897
|
+
interactionStyle: mergedInteractionStyle,
|
|
3898
|
+
onSession: this.trackTransformSession(options?.onSession),
|
|
3899
|
+
handleIdPrefix,
|
|
3900
|
+
sizeAnchor: options?.sizeAnchor,
|
|
3901
|
+
restoreOriginalToGraphics,
|
|
3902
|
+
isOnGraphicsLayer,
|
|
3903
|
+
onSettled,
|
|
3904
|
+
onClickAwayGraphicsPick
|
|
3905
|
+
}), (handle) => {
|
|
3906
|
+
this.activeTransform = handle;
|
|
3907
|
+
return () => {
|
|
3908
|
+
if (this.activeTransform === handle) this.activeTransform = null;
|
|
3909
|
+
};
|
|
3910
|
+
}, extraSettle);
|
|
3911
|
+
}
|
|
3912
|
+
/**
|
|
3913
|
+
* One-call selection-sync: mutate the live group transform's member set in
|
|
3914
|
+
* place, or start a fresh `editMany` when no transform is live (#191). This
|
|
3915
|
+
* owns the "live session → `setMembers`, else → `editMany`" branch that every
|
|
3916
|
+
* host otherwise hand-rolls against `activeTransformSession`.
|
|
3917
|
+
*
|
|
3918
|
+
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
3919
|
+
* is cleared inside the settle path, before control returns to host code — so
|
|
3920
|
+
* unlike a host's own `activeTransformSession ? setMembers : editMany` check,
|
|
3921
|
+
* this can never observe a stale ref pointing at an already-settled session.
|
|
3922
|
+
* The settled-race fall-through hosts had to hand-roll for that gap is owned
|
|
3923
|
+
* here instead: a `setMembers` call that (implausibly) still returns
|
|
3924
|
+
* `undefined` falls through to a fresh `editMany` rather than silently
|
|
3925
|
+
* dropping the selection.
|
|
3926
|
+
*
|
|
3927
|
+
* `measures: []` closes any live session (committing every member) and is a
|
|
3928
|
+
* no-op while idle. With a live session, `options` is ignored — session-scoped
|
|
3929
|
+
* state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
|
|
3930
|
+
* `setMembers`'s contract; `options` applies only when this call starts a
|
|
3931
|
+
* fresh `editMany`. Exit snapshots are delivered via the session's
|
|
3932
|
+
* `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
|
|
3933
|
+
* including an Escape / preempt / destroy abort the host did not itself
|
|
3934
|
+
* initiate — is observed via the session's `onSettled` (#194); together
|
|
3935
|
+
* `onCommit` and `onSettled` make the underlying `editMany` promise
|
|
3936
|
+
* irrelevant to hosts. That promise is owned by the facade; its abort
|
|
3937
|
+
* rejections are swallowed here (non-abort errors surface as unhandled
|
|
3938
|
+
* rejections). Throws `TypeError` synchronously on duplicate ids, from
|
|
3939
|
+
* either path. Starting a fresh transform preempts any other in-flight
|
|
3940
|
+
* draw/edit exactly like `editMany`.
|
|
3941
|
+
*/
|
|
3942
|
+
syncTransformMembers(measures, options) {
|
|
3943
|
+
this.assertAlive();
|
|
3944
|
+
const session = this.activeTransformSessionRef;
|
|
3945
|
+
if (session) {
|
|
3946
|
+
if (session.setMembers(measures) !== void 0) return;
|
|
3947
|
+
}
|
|
3948
|
+
if (measures.length === 0) return;
|
|
3949
|
+
this.editMany([...measures], options).catch(ignoreAbort);
|
|
2617
3950
|
}
|
|
2618
3951
|
/**
|
|
2619
3952
|
* Subscribe to picks on committed control measures.
|
|
@@ -2681,6 +4014,15 @@ var TacticalDraw = class {
|
|
|
2681
4014
|
return this.activeInteraction.session;
|
|
2682
4015
|
}
|
|
2683
4016
|
/**
|
|
4017
|
+
* The live `editMany` group-transform session, or `null` while idle (and
|
|
4018
|
+
* during any other interaction). Separate from `activeSession` because
|
|
4019
|
+
* `TransformSession` isn't part of its `DrawSession | EditSession` union —
|
|
4020
|
+
* see `activeTransformSessionRef`. Safe to read after `destroy()`.
|
|
4021
|
+
*/
|
|
4022
|
+
get activeTransformSession() {
|
|
4023
|
+
return this.activeTransformSessionRef;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
2684
4026
|
* Tear down the façade. Releases only layer slots the façade created; host
|
|
2685
4027
|
* supplied layer ids are left untouched. Safe to call multiple times. After
|
|
2686
4028
|
* the first call every public method throws `TacticalDrawDestroyedError`.
|
|
@@ -2690,6 +4032,8 @@ var TacticalDraw = class {
|
|
|
2690
4032
|
this.destroyed = true;
|
|
2691
4033
|
if (this.activeInteraction.isActive) {
|
|
2692
4034
|
this.activeEdit = null;
|
|
4035
|
+
this.activeTransform = null;
|
|
4036
|
+
this.activeTransformSessionRef = null;
|
|
2693
4037
|
this.activeInteraction.cancel("destroyed");
|
|
2694
4038
|
} else this.activeInteraction.clear();
|
|
2695
4039
|
this.adapter.offViewChange(this.handleViewChange);
|
|
@@ -2709,7 +4053,8 @@ var TacticalDraw = class {
|
|
|
2709
4053
|
* from a host pick handler observes a settled session.
|
|
2710
4054
|
*/
|
|
2711
4055
|
routePick(layerId, event) {
|
|
2712
|
-
|
|
4056
|
+
const excluded = this.excludedInteractionIds();
|
|
4057
|
+
if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
|
|
2713
4058
|
this.deferredGraphicsPick = event;
|
|
2714
4059
|
return;
|
|
2715
4060
|
}
|
|
@@ -2748,9 +4093,19 @@ var TacticalDraw = class {
|
|
|
2748
4093
|
return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
|
|
2749
4094
|
}
|
|
2750
4095
|
/**
|
|
2751
|
-
* Resolve handle slots across built-in → façade →
|
|
2752
|
-
* per-property shallow merge. Explicit `undefined`
|
|
2753
|
-
* layer unsets the corresponding lower-priority field
|
|
4096
|
+
* Resolve handle slots (plus `boxOutline`) across built-in → façade →
|
|
4097
|
+
* per-call with per-slot, per-property shallow merge. Explicit `undefined`
|
|
4098
|
+
* at a higher-priority layer unsets the corresponding lower-priority field
|
|
4099
|
+
* (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
|
|
4100
|
+
* `guide` layer-style path) — the edit controller reads it off
|
|
4101
|
+
* `properties.style` on the box outline `LineString` (ADR-0017).
|
|
4102
|
+
*
|
|
4103
|
+
* `boxPadding` is a scalar, not a mergeable style object, so it resolves
|
|
4104
|
+
* separately with the same façade → per-call precedence and the same
|
|
4105
|
+
* explicit-undefined-wins rule: a `boxPadding` key present at a
|
|
4106
|
+
* higher-priority layer (even set to `undefined`) wins over a lower layer's
|
|
4107
|
+
* value. There is no built-in `boxPadding` — an absent value at every layer
|
|
4108
|
+
* falls through to the controllers' own `?? 0` default.
|
|
2754
4109
|
*
|
|
2755
4110
|
* Returns `undefined` only when no slot would carry any value — keeps the
|
|
2756
4111
|
* downstream "no style on the feature" path intact for tests that assert
|
|
@@ -2762,6 +4117,10 @@ var TacticalDraw = class {
|
|
|
2762
4117
|
const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
|
|
2763
4118
|
if (merged) out[slot] = merged;
|
|
2764
4119
|
}
|
|
4120
|
+
const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
|
|
4121
|
+
if (boxOutline) out.boxOutline = boxOutline;
|
|
4122
|
+
const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
|
|
4123
|
+
if (typeof boxPadding === "number") out.boxPadding = boxPadding;
|
|
2765
4124
|
return out;
|
|
2766
4125
|
}
|
|
2767
4126
|
assertAlive() {
|
|
@@ -2778,10 +4137,11 @@ const BUILT_IN_GRAPHICS_STYLE = {
|
|
|
2778
4137
|
strokeWidth: 2
|
|
2779
4138
|
};
|
|
2780
4139
|
/**
|
|
2781
|
-
* Built-in defaults for the interaction-affordance slots
|
|
2782
|
-
*
|
|
2783
|
-
*
|
|
2784
|
-
*
|
|
4140
|
+
* Built-in defaults for the interaction-affordance slots (ADR-0006, extended
|
|
4141
|
+
* for the ADR-0017 transform box). The scale (corner) grips and the rotate
|
|
4142
|
+
* grip are white-filled with the same blue stroke as the box outline, so the
|
|
4143
|
+
* box reads as one coherent affordance. Vertex/midpoint keep their existing
|
|
4144
|
+
* white/gray family.
|
|
2785
4145
|
*/
|
|
2786
4146
|
const BUILT_IN_INTERACTION_STYLE = {
|
|
2787
4147
|
guide: {
|
|
@@ -2789,6 +4149,10 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2789
4149
|
strokeWidth: 1,
|
|
2790
4150
|
strokeDash: [6, 4]
|
|
2791
4151
|
},
|
|
4152
|
+
boxOutline: {
|
|
4153
|
+
strokeColor: "#4f7cff",
|
|
4154
|
+
strokeWidth: 1
|
|
4155
|
+
},
|
|
2792
4156
|
vertexHandle: {
|
|
2793
4157
|
pointRadius: 6,
|
|
2794
4158
|
fillColor: "#ffffff",
|
|
@@ -2801,21 +4165,23 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2801
4165
|
strokeColor: "#6b7280",
|
|
2802
4166
|
strokeWidth: 1.5
|
|
2803
4167
|
},
|
|
2804
|
-
|
|
2805
|
-
pointRadius:
|
|
2806
|
-
fillColor: "#
|
|
2807
|
-
strokeColor: "#
|
|
4168
|
+
scaleHandle: {
|
|
4169
|
+
pointRadius: 5,
|
|
4170
|
+
fillColor: "#ffffff",
|
|
4171
|
+
strokeColor: "#4f7cff",
|
|
4172
|
+
strokeWidth: 2
|
|
2808
4173
|
},
|
|
2809
4174
|
rotateHandle: {
|
|
2810
|
-
pointRadius:
|
|
2811
|
-
fillColor: "#
|
|
2812
|
-
strokeColor: "#
|
|
4175
|
+
pointRadius: 5,
|
|
4176
|
+
fillColor: "#ffffff",
|
|
4177
|
+
strokeColor: "#4f7cff",
|
|
4178
|
+
strokeWidth: 2
|
|
2813
4179
|
}
|
|
2814
4180
|
};
|
|
2815
4181
|
const HANDLE_SLOTS = [
|
|
2816
4182
|
"vertexHandle",
|
|
2817
4183
|
"midpointHandle",
|
|
2818
|
-
"
|
|
4184
|
+
"scaleHandle",
|
|
2819
4185
|
"rotateHandle"
|
|
2820
4186
|
];
|
|
2821
4187
|
/**
|
|
@@ -2860,4 +4226,22 @@ function resolveSlot(adapter, supplied, owned) {
|
|
|
2860
4226
|
return id;
|
|
2861
4227
|
}
|
|
2862
4228
|
//#endregion
|
|
2863
|
-
|
|
4229
|
+
//#region src/world-wrap.ts
|
|
4230
|
+
/**
|
|
4231
|
+
* Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
|
|
4232
|
+
*
|
|
4233
|
+
* Map engines that wrap pointer longitudes into [-180, 180] (OpenLayers'
|
|
4234
|
+
* `toLonLat`, MapLibre's globe unproject) make positions jump ~360° when a
|
|
4235
|
+
* gesture crosses the antimeridian. Adapters use this to keep delivered
|
|
4236
|
+
* longitudes continuous with the view center, and to project coordinates on
|
|
4237
|
+
* the world copy the view is actually looking at.
|
|
4238
|
+
*/
|
|
4239
|
+
function nearestWorldCopyLng(lng, refLng) {
|
|
4240
|
+
return lng + 360 * Math.round((refLng - lng) / 360);
|
|
4241
|
+
}
|
|
4242
|
+
/** `nearestWorldCopyLng` applied to a lon/lat position. */
|
|
4243
|
+
function nearestWorldCopyPosition(position, refLng) {
|
|
4244
|
+
return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
|
|
4245
|
+
}
|
|
4246
|
+
//#endregion
|
|
4247
|
+
export { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, tryControlMeasureIdFromFeature };
|