@orbat-mapper/tactical-draw 0.2.0-alpha.0 → 0.2.0-alpha.10
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/README.md +12 -12
- package/dist/index.d.mts +450 -32
- package/dist/index.mjs +1814 -505
- 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.
|
|
@@ -511,17 +511,29 @@ function combineWithHostSignal(hostSignal) {
|
|
|
511
511
|
* is one entry here (ADR-0020).
|
|
512
512
|
*/
|
|
513
513
|
const PIXEL_SIZE_OPTIONS = {
|
|
514
|
+
boundary: {
|
|
515
|
+
pixels: "echelonSizePixels",
|
|
516
|
+
meters: "echelonSize"
|
|
517
|
+
},
|
|
518
|
+
"battle-position": {
|
|
519
|
+
pixels: "echelonSizePixels",
|
|
520
|
+
meters: "echelonSize"
|
|
521
|
+
},
|
|
522
|
+
"strong-point": {
|
|
523
|
+
pixels: "echelonSizePixels",
|
|
524
|
+
meters: "echelonSize"
|
|
525
|
+
},
|
|
514
526
|
flot: {
|
|
515
527
|
pixels: "radiusPixels",
|
|
516
528
|
meters: "radius"
|
|
517
529
|
},
|
|
518
530
|
"fortified-line": {
|
|
519
|
-
pixels: "
|
|
520
|
-
meters: "
|
|
531
|
+
pixels: "toothSizePixels",
|
|
532
|
+
meters: "toothSize"
|
|
521
533
|
},
|
|
522
534
|
"fortified-area": {
|
|
523
|
-
pixels: "
|
|
524
|
-
meters: "
|
|
535
|
+
pixels: "toothSizePixels",
|
|
536
|
+
meters: "toothSize"
|
|
525
537
|
},
|
|
526
538
|
"antitank-ditch-under-construction": {
|
|
527
539
|
pixels: "toothHeightPixels",
|
|
@@ -588,7 +600,7 @@ function bakePixelSizeToGround(measure, adapter) {
|
|
|
588
600
|
/**
|
|
589
601
|
* Stamp the adapter's current `metersPerPixel` onto a measure that sizes itself
|
|
590
602
|
* in pixels, so the generator resolves pixel options (`radiusPixels`,
|
|
591
|
-
* `
|
|
603
|
+
* `toothSizePixels`, `toothHeightPixels`) against the live map zoom. Returns the
|
|
592
604
|
* measure unchanged when the adapter has no usable resolution or the
|
|
593
605
|
* kind/options don't opt into pixel sizing.
|
|
594
606
|
*/
|
|
@@ -687,7 +699,7 @@ function runInteraction(spec) {
|
|
|
687
699
|
clearLayers();
|
|
688
700
|
}
|
|
689
701
|
function commit(produce) {
|
|
690
|
-
if (settled) return;
|
|
702
|
+
if (settled) return void 0;
|
|
691
703
|
let value;
|
|
692
704
|
try {
|
|
693
705
|
value = produce();
|
|
@@ -695,13 +707,20 @@ function runInteraction(spec) {
|
|
|
695
707
|
settled = true;
|
|
696
708
|
teardown();
|
|
697
709
|
rejectFn(err);
|
|
698
|
-
if (onSettled) onSettled(
|
|
710
|
+
if (onSettled) onSettled({
|
|
711
|
+
status: "rejected",
|
|
712
|
+
error: err
|
|
713
|
+
});
|
|
699
714
|
return;
|
|
700
715
|
}
|
|
701
716
|
settled = true;
|
|
702
717
|
teardown();
|
|
703
718
|
resolveFn(value);
|
|
704
|
-
if (onSettled) onSettled(
|
|
719
|
+
if (onSettled) onSettled({
|
|
720
|
+
status: "committed",
|
|
721
|
+
value
|
|
722
|
+
});
|
|
723
|
+
return value;
|
|
705
724
|
}
|
|
706
725
|
function fail(error) {
|
|
707
726
|
if (settled) return;
|
|
@@ -711,7 +730,10 @@ function runInteraction(spec) {
|
|
|
711
730
|
controllerOnAbort();
|
|
712
731
|
} catch {}
|
|
713
732
|
rejectFn(error);
|
|
714
|
-
if (onSettled) onSettled(
|
|
733
|
+
if (onSettled) onSettled({
|
|
734
|
+
status: "rejected",
|
|
735
|
+
error
|
|
736
|
+
});
|
|
715
737
|
}
|
|
716
738
|
const ctx = {
|
|
717
739
|
get settled() {
|
|
@@ -761,6 +783,82 @@ function roundControlPoints(points) {
|
|
|
761
783
|
return points.map(roundControlPoint);
|
|
762
784
|
}
|
|
763
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
|
|
764
862
|
//#region src/tactical-draw/draw-controller.ts
|
|
765
863
|
const PREVIEW_CM_ID$1 = "__td_preview__";
|
|
766
864
|
const PREVIEW_CURSOR$1 = "crosshair";
|
|
@@ -778,7 +876,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
|
|
|
778
876
|
* kinds need no host UI.
|
|
779
877
|
*/
|
|
780
878
|
function runFixedLengthDraw(options) {
|
|
781
|
-
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;
|
|
782
880
|
const metadata = getControlMeasureMetadata(kind);
|
|
783
881
|
const rule = metadata.rule;
|
|
784
882
|
const min = metadata.minCoordinates;
|
|
@@ -788,6 +886,8 @@ function runFixedLengthDraw(options) {
|
|
|
788
886
|
else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
|
|
789
887
|
else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
|
|
790
888
|
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
889
|
+
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
890
|
+
const isAreaKind = metadata.geometry === "area";
|
|
791
891
|
const committed = [];
|
|
792
892
|
let rubberBand;
|
|
793
893
|
let ctx;
|
|
@@ -812,11 +912,15 @@ function runFixedLengthDraw(options) {
|
|
|
812
912
|
const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
|
|
813
913
|
if (raw.length === 0) {
|
|
814
914
|
ctx.writeLayer(previewLayer, []);
|
|
915
|
+
ctx.writeLayer(guideLayer, []);
|
|
815
916
|
return;
|
|
816
917
|
}
|
|
817
918
|
const previewThreshold = rule?.minimumPreviewPoints ?? required;
|
|
818
919
|
const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
|
|
819
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);
|
|
820
924
|
}
|
|
821
925
|
function rescaleOnViewChange() {
|
|
822
926
|
if (!measureUsesAdapterResolution({
|
|
@@ -844,7 +948,7 @@ function runFixedLengthDraw(options) {
|
|
|
844
948
|
return runInteraction({
|
|
845
949
|
adapter,
|
|
846
950
|
signal,
|
|
847
|
-
layers: [previewLayer],
|
|
951
|
+
layers: [previewLayer, guideLayer],
|
|
848
952
|
onViewChange: rescaleOnViewChange,
|
|
849
953
|
onSettled,
|
|
850
954
|
attach(c) {
|
|
@@ -903,79 +1007,6 @@ function outlinePreview$1(points) {
|
|
|
903
1007
|
return features;
|
|
904
1008
|
}
|
|
905
1009
|
//#endregion
|
|
906
|
-
//#region src/tactical-draw/guide.ts
|
|
907
|
-
/**
|
|
908
|
-
* Stable preview-layer ids and emission rules for the rubber-band guide
|
|
909
|
-
* (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
|
|
910
|
-
* single styling rule across both interactions.
|
|
911
|
-
*/
|
|
912
|
-
const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
|
|
913
|
-
const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
|
|
914
|
-
const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
|
|
915
|
-
/**
|
|
916
|
-
* A kind is fixed-length when its draw rule pins the slot count
|
|
917
|
-
* (`canonicalPointCount`) or its metadata declares `min === max`. The
|
|
918
|
-
* generator preview is already authoritative for these, so the rubber-band
|
|
919
|
-
* guide adds no information.
|
|
920
|
-
*/
|
|
921
|
-
function isFixedLength(metadata) {
|
|
922
|
-
if (metadata.rule?.canonicalPointCount !== void 0) return true;
|
|
923
|
-
const min = metadata.minCoordinates;
|
|
924
|
-
const max = metadata.maxCoordinates;
|
|
925
|
-
return typeof min === "number" && typeof max === "number" && min === max;
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Guide is mechanically suppressed for kinds where it carries no information:
|
|
929
|
-
* point kinds and fixed-length kinds. Independent of the host's `guide`
|
|
930
|
-
* opt-out.
|
|
931
|
-
*/
|
|
932
|
-
function shouldSuppressGuide(metadata) {
|
|
933
|
-
return metadata.geometry === "point" || isFixedLength(metadata);
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Build the guide LineString features for a point list.
|
|
937
|
-
* - Open polyline through `points` once `points.length >= 2`.
|
|
938
|
-
* - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
|
|
939
|
-
*
|
|
940
|
-
* Callers are responsible for the opt-out (`guide: false`) and the suppression
|
|
941
|
-
* check via [[shouldSuppressGuide]] — this function only emits geometry.
|
|
942
|
-
*/
|
|
943
|
-
function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
|
|
944
|
-
const features = [];
|
|
945
|
-
const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
|
|
946
|
-
if (spine.length >= 2) {
|
|
947
|
-
const open = {
|
|
948
|
-
type: "Feature",
|
|
949
|
-
id: PREVIEW_GUIDE_OPEN_ID,
|
|
950
|
-
geometry: {
|
|
951
|
-
type: "LineString",
|
|
952
|
-
coordinates: spine.slice()
|
|
953
|
-
},
|
|
954
|
-
properties: {
|
|
955
|
-
part: "guide",
|
|
956
|
-
segment: "open"
|
|
957
|
-
}
|
|
958
|
-
};
|
|
959
|
-
features.push(open);
|
|
960
|
-
}
|
|
961
|
-
if (isAreaKind && spine.length >= 3) {
|
|
962
|
-
const closing = {
|
|
963
|
-
type: "Feature",
|
|
964
|
-
id: PREVIEW_GUIDE_CLOSING_ID,
|
|
965
|
-
geometry: {
|
|
966
|
-
type: "LineString",
|
|
967
|
-
coordinates: [spine[spine.length - 1], spine[0]]
|
|
968
|
-
},
|
|
969
|
-
properties: {
|
|
970
|
-
part: "guide",
|
|
971
|
-
segment: "closing"
|
|
972
|
-
}
|
|
973
|
-
};
|
|
974
|
-
features.push(closing);
|
|
975
|
-
}
|
|
976
|
-
return features;
|
|
977
|
-
}
|
|
978
|
-
//#endregion
|
|
979
1010
|
//#region src/tactical-draw/draw-controller-variable.ts
|
|
980
1011
|
const PREVIEW_CM_ID = "__td_preview__";
|
|
981
1012
|
const PREVIEW_CURSOR = "crosshair";
|
|
@@ -994,6 +1025,7 @@ function runVariableLengthDraw(options) {
|
|
|
994
1025
|
const max = metadata.maxCoordinates;
|
|
995
1026
|
const rule = metadata.rule;
|
|
996
1027
|
const trailingFixed = rule?.trailingFixedSlots ?? 0;
|
|
1028
|
+
const closedRing = rule?.closedRing ?? false;
|
|
997
1029
|
const ruleMin = rule?.minimumUserPoints;
|
|
998
1030
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
999
1031
|
const isAreaKind = metadata.geometry === "area";
|
|
@@ -1085,20 +1117,20 @@ function runVariableLengthDraw(options) {
|
|
|
1085
1117
|
if (ctx.settled) return;
|
|
1086
1118
|
const pos = roundControlPoint(event.coordinate);
|
|
1087
1119
|
const lastSpineIdx = committed.length - 1 - trailingFixed;
|
|
1120
|
+
const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
|
|
1088
1121
|
for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
|
|
1089
|
-
if (i
|
|
1122
|
+
if (finishIndices.includes(i) && canCommitNow()) ctx.commit(buildSnapshot);
|
|
1090
1123
|
return;
|
|
1091
1124
|
}
|
|
1092
|
-
if (lastSpineIdx >= 0 && canCommitNow()) {
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
}
|
|
1125
|
+
if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
|
|
1126
|
+
const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
|
|
1127
|
+
if (!finishPixel) continue;
|
|
1128
|
+
const tol = mapEventHitTolerance(event.originalEvent);
|
|
1129
|
+
const dx = event.pixel[0] - finishPixel[0];
|
|
1130
|
+
const dy = event.pixel[1] - finishPixel[1];
|
|
1131
|
+
if (dx * dx + dy * dy <= tol * tol) {
|
|
1132
|
+
ctx.commit(buildSnapshot);
|
|
1133
|
+
return;
|
|
1102
1134
|
}
|
|
1103
1135
|
}
|
|
1104
1136
|
if (typeof max === "number" && committed.length >= max) return;
|
|
@@ -1148,14 +1180,14 @@ function runVariableLengthDraw(options) {
|
|
|
1148
1180
|
renderPreview();
|
|
1149
1181
|
}
|
|
1150
1182
|
function buildSnapshot() {
|
|
1151
|
-
const built = {
|
|
1183
|
+
const built = withCommittedAdapterMeasureOptions({
|
|
1152
1184
|
id: generateId(),
|
|
1153
1185
|
kind,
|
|
1154
1186
|
controlPoints: committed.map((p) => [...p]),
|
|
1155
1187
|
...measureOptions !== void 0 ? { options: measureOptions } : {},
|
|
1156
1188
|
...measureStyle !== void 0 ? { style: measureStyle } : {},
|
|
1157
1189
|
...measureProperties !== void 0 ? { properties: measureProperties } : {}
|
|
1158
|
-
};
|
|
1190
|
+
}, adapter);
|
|
1159
1191
|
const measure = sizeAnchor === "screen" ? built : bakePixelSizeToGround(built, adapter);
|
|
1160
1192
|
return {
|
|
1161
1193
|
measure: cloneControlMeasure(measure),
|
|
@@ -1238,63 +1270,14 @@ function outlinePreview(points) {
|
|
|
1238
1270
|
//#endregion
|
|
1239
1271
|
//#region src/tactical-draw/handles.ts
|
|
1240
1272
|
/**
|
|
1241
|
-
* Compute the anchor used by translate and rotate handles. The anchor is the
|
|
1242
|
-
* centroid (arithmetic mean) of the control-point list — stable, kind
|
|
1243
|
-
* agnostic, and well-defined for any non-empty point set.
|
|
1244
|
-
*/
|
|
1245
|
-
function computeHandleAnchor(controlPoints) {
|
|
1246
|
-
if (controlPoints.length === 0) return null;
|
|
1247
|
-
let sx = 0;
|
|
1248
|
-
let sy = 0;
|
|
1249
|
-
for (const p of controlPoints) {
|
|
1250
|
-
sx += p[0];
|
|
1251
|
-
sy += p[1];
|
|
1252
|
-
}
|
|
1253
|
-
return [sx / controlPoints.length, sy / controlPoints.length];
|
|
1254
|
-
}
|
|
1255
|
-
/**
|
|
1256
|
-
* How far past the outermost control point the rotate grip sits. `1.3` places
|
|
1257
|
-
* it 30% beyond the farthest vertex, clearing the geometry while staying
|
|
1258
|
-
* proportional to the shape's size.
|
|
1259
|
-
*/
|
|
1260
|
-
const ROTATE_HANDLE_REACH_FACTOR = 1.3;
|
|
1261
|
-
/**
|
|
1262
|
-
* Position of the rotate grip. Unlike the translate handle — which sits on the
|
|
1263
|
-
* centroid pivot — the rotate handle needs a lever arm, so it is offset from
|
|
1264
|
-
* the centroid along the ray toward the control point farthest from it, just
|
|
1265
|
-
* outside the shape. Anchoring to a *material* point (rather than the
|
|
1266
|
-
* axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
|
|
1267
|
-
* grip orbits with the shape as it rotates, since the farthest vertex rotates
|
|
1268
|
-
* with it. Falls back to the centroid when every point coincides (no
|
|
1269
|
-
* well-defined direction) or the list is empty.
|
|
1270
|
-
*/
|
|
1271
|
-
function computeRotateHandlePosition(controlPoints) {
|
|
1272
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1273
|
-
if (!anchor) return null;
|
|
1274
|
-
let farthest = null;
|
|
1275
|
-
let maxDistSq = 0;
|
|
1276
|
-
for (const p of controlPoints) {
|
|
1277
|
-
const dx = p[0] - anchor[0];
|
|
1278
|
-
const dy = p[1] - anchor[1];
|
|
1279
|
-
const distSq = dx * dx + dy * dy;
|
|
1280
|
-
if (distSq > maxDistSq) {
|
|
1281
|
-
maxDistSq = distSq;
|
|
1282
|
-
farthest = p;
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
if (!farthest) return anchor;
|
|
1286
|
-
return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
|
|
1287
|
-
}
|
|
1288
|
-
/**
|
|
1289
1273
|
* Axis-aligned bounding-box center of the control points — the midpoint of
|
|
1290
|
-
* `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1274
|
+
* `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
|
|
1275
|
+
* `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
|
|
1276
|
+
* the first paint), matching mainstream drawing tools' "turn about the visual
|
|
1277
|
+
* middle" convention. Returns `null` for an empty list.
|
|
1294
1278
|
*
|
|
1295
|
-
* Exported because the rotation pivot is *not* a handle
|
|
1296
|
-
*
|
|
1297
|
-
* controller computes it directly at drag-start. See ADR-0016.
|
|
1279
|
+
* Exported because the rotation pivot is *not* a handle — the edit controller
|
|
1280
|
+
* consults it directly. See ADR-0016/ADR-0017.
|
|
1298
1281
|
*/
|
|
1299
1282
|
function computeBoundingBoxCenter(controlPoints) {
|
|
1300
1283
|
if (controlPoints.length === 0) return null;
|
|
@@ -1320,26 +1303,26 @@ function segmentMidpoint(a, b) {
|
|
|
1320
1303
|
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1321
1304
|
}
|
|
1322
1305
|
/**
|
|
1323
|
-
* The single source of truth for
|
|
1324
|
-
* `EditMode → which handles` gating (ADR-0016). Pure and
|
|
1325
|
-
* positions are lonLat. Handles are emitted in **pick
|
|
1326
|
-
* `[vertices…, midpoints
|
|
1327
|
-
* first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1306
|
+
* The single source of truth for reshape-handle placement and the
|
|
1307
|
+
* `EditMode → which handles` gating (ADR-0016, narrowed by ADR-0017). Pure and
|
|
1308
|
+
* projection-free: positions are lonLat. Handles are emitted in **pick
|
|
1309
|
+
* priority order** — `[vertices…, midpoints…]` — so `pickHandle` can return
|
|
1310
|
+
* the first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1328
1311
|
* midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
|
|
1329
1312
|
* projections of this set; the drag controller seeds gestures from it too.
|
|
1330
1313
|
*
|
|
1331
1314
|
* Emits handles only for modes present in `modes`:
|
|
1332
1315
|
* - `reshape`: vertex handle at every control point + midpoint handle on
|
|
1333
1316
|
* every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
|
|
1334
|
-
* - `delete`: vertex handles only (tapped to remove), no
|
|
1335
|
-
* - `translate`: a single translate handle at the centroid anchor;
|
|
1336
|
-
* - `rotate`: a single rotate grip offset outside the shape.
|
|
1317
|
+
* - `delete`: vertex handles only (tapped to remove), no midpoints.
|
|
1337
1318
|
*
|
|
1338
|
-
* `
|
|
1339
|
-
* are
|
|
1319
|
+
* `transform` mode never contributes handles here — the box's corner/rotate
|
|
1320
|
+
* grips are pixel-space and placed by `transform-box.ts`, then rendered as
|
|
1321
|
+
* `Handle`s of kind `scale`/`rotate` by the edit controller. Returns `[]` for
|
|
1322
|
+
* an empty control-point list.
|
|
1340
1323
|
*/
|
|
1341
1324
|
function buildHandleSet(controlPoints, modes, options = {}) {
|
|
1342
|
-
const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
|
|
1325
|
+
const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
|
|
1343
1326
|
const handles = [];
|
|
1344
1327
|
const wantsReshape = modes.includes("reshape");
|
|
1345
1328
|
if (wantsReshape || modes.includes("delete")) {
|
|
@@ -1349,37 +1332,24 @@ function buildHandleSet(controlPoints, modes, options = {}) {
|
|
|
1349
1332
|
position: [...controlPoints[i]]
|
|
1350
1333
|
});
|
|
1351
1334
|
if (wantsReshape && includeMidpoints) {
|
|
1352
|
-
const
|
|
1353
|
-
for (let i = 0; i <
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1335
|
+
const edgeCount = closedRing && trailingFixedSlots === 0 && controlPoints.length >= 3 ? controlPoints.length : controlPoints.length - 1 - trailingFixedSlots;
|
|
1336
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
1337
|
+
const end = (i + 1) % controlPoints.length;
|
|
1338
|
+
handles.push({
|
|
1339
|
+
kind: "midpoint",
|
|
1340
|
+
index: i,
|
|
1341
|
+
position: segmentMidpoint(controlPoints[i], controlPoints[end])
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1358
1344
|
}
|
|
1359
1345
|
}
|
|
1360
|
-
if (modes.includes("translate")) {
|
|
1361
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1362
|
-
if (anchor) handles.push({
|
|
1363
|
-
kind: "translate",
|
|
1364
|
-
index: 0,
|
|
1365
|
-
position: anchor
|
|
1366
|
-
});
|
|
1367
|
-
}
|
|
1368
|
-
if (modes.includes("rotate")) {
|
|
1369
|
-
const rotatePos = computeRotateHandlePosition(controlPoints);
|
|
1370
|
-
if (rotatePos) handles.push({
|
|
1371
|
-
kind: "rotate",
|
|
1372
|
-
index: 0,
|
|
1373
|
-
position: rotatePos
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
1346
|
return handles;
|
|
1377
1347
|
}
|
|
1378
1348
|
function handleFeatureId(prefix, kind, index) {
|
|
1379
1349
|
switch (kind) {
|
|
1380
1350
|
case "vertex": return `${prefix}:handle-vertex:${index}`;
|
|
1381
1351
|
case "midpoint": return `${prefix}:handle-midpoint:${index}`;
|
|
1382
|
-
case "
|
|
1352
|
+
case "scale": return `${prefix}:handle-scale:${index}`;
|
|
1383
1353
|
case "rotate": return `${prefix}:handle-rotate`;
|
|
1384
1354
|
}
|
|
1385
1355
|
}
|
|
@@ -1387,16 +1357,18 @@ function handleStyleFor(kind, interactionStyle) {
|
|
|
1387
1357
|
switch (kind) {
|
|
1388
1358
|
case "vertex": return interactionStyle?.vertexHandle;
|
|
1389
1359
|
case "midpoint": return interactionStyle?.midpointHandle;
|
|
1390
|
-
case "
|
|
1360
|
+
case "scale": return interactionStyle?.scaleHandle;
|
|
1391
1361
|
case "rotate": return interactionStyle?.rotateHandle;
|
|
1392
1362
|
}
|
|
1393
1363
|
}
|
|
1394
1364
|
/**
|
|
1395
1365
|
* Project a handle set into the GeoJSON features that belong on the handles
|
|
1396
|
-
* layer — the render projection of `buildHandleSet` (ADR-0016)
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1366
|
+
* layer — the render projection of `buildHandleSet` (ADR-0016), also reused by
|
|
1367
|
+
* the edit controller to render the transform box's corner/rotate `Handle`s
|
|
1368
|
+
* (ADR-0017). Stable `featureIdPrefix`-namespaced ids let multiple edits share
|
|
1369
|
+
* a handles layer without colliding; per-slot `interactionStyle` rides on
|
|
1370
|
+
* `properties.style`. Pure: handle positions are copied, never aliased into
|
|
1371
|
+
* the output geometry.
|
|
1400
1372
|
*/
|
|
1401
1373
|
function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
1402
1374
|
return handles.map((handle) => {
|
|
@@ -1419,24 +1391,140 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
|
1419
1391
|
};
|
|
1420
1392
|
});
|
|
1421
1393
|
}
|
|
1422
|
-
function pixelDistance(a, b) {
|
|
1394
|
+
function pixelDistance$2(a, b) {
|
|
1423
1395
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1424
1396
|
}
|
|
1425
1397
|
/**
|
|
1426
1398
|
* The pick projection of `buildHandleSet` (ADR-0016): the first handle within
|
|
1427
1399
|
* `tolerance` pixels of `pixel`, tested in the set's priority order. Because
|
|
1428
|
-
* `buildHandleSet` orders vertices before midpoints
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1400
|
+
* `buildHandleSet` orders vertices before midpoints, "first within tolerance"
|
|
1401
|
+
* preserves the legacy "vertex wins over a pixel-closer midpoint" behaviour.
|
|
1402
|
+
* Returns the full `Handle` so the drag controller can seed a gesture from its
|
|
1403
|
+
* `position` without recomputing placement. Handles whose position fails to
|
|
1404
|
+
* project (off-screen / degenerate projection) are skipped. Returns `null`
|
|
1405
|
+
* when nothing is within tolerance.
|
|
1434
1406
|
*/
|
|
1435
1407
|
function pickHandle(handles, pixel, tolerance, toPixel) {
|
|
1436
1408
|
for (const handle of handles) {
|
|
1437
1409
|
const projected = toPixel(handle.position);
|
|
1438
|
-
if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
|
|
1410
|
+
if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
|
|
1411
|
+
}
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
function rotate(x, y, angle) {
|
|
1415
|
+
const cos = Math.cos(angle);
|
|
1416
|
+
const sin = Math.sin(angle);
|
|
1417
|
+
return [x * cos - y * sin, x * sin + y * cos];
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
|
|
1421
|
+
* Rotates the points by `-angle` about their centroid, takes the axis-aligned
|
|
1422
|
+
* bounding box in that de-rotated frame, then re-expresses the result as an
|
|
1423
|
+
* oriented box at `angle` — so the box always has exactly the requested tilt,
|
|
1424
|
+
* sized to just contain the (de-rotated) points. Half-dimensions are floored
|
|
1425
|
+
* at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
|
|
1426
|
+
* pickable box. Returns `null` for an empty list — there is nothing to fit.
|
|
1427
|
+
*
|
|
1428
|
+
* `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
|
|
1429
|
+
* the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
|
|
1430
|
+
* `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
|
|
1431
|
+
* the rendered geometry. Everything downstream that reads a fitted box
|
|
1432
|
+
* (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
|
|
1433
|
+
* `halfWidth`/`halfHeight` alone, so the padding flows through to corner
|
|
1434
|
+
* placement, the rotate grip, and picking automatically — no other function
|
|
1435
|
+
* needs to know about it.
|
|
1436
|
+
*/
|
|
1437
|
+
function fitTransformBox(pixels, angle, padding = 0) {
|
|
1438
|
+
if (pixels.length === 0) return null;
|
|
1439
|
+
let cx = 0;
|
|
1440
|
+
let cy = 0;
|
|
1441
|
+
for (const p of pixels) {
|
|
1442
|
+
cx += p[0];
|
|
1443
|
+
cy += p[1];
|
|
1444
|
+
}
|
|
1445
|
+
cx /= pixels.length;
|
|
1446
|
+
cy /= pixels.length;
|
|
1447
|
+
let minX = Infinity;
|
|
1448
|
+
let minY = Infinity;
|
|
1449
|
+
let maxX = -Infinity;
|
|
1450
|
+
let maxY = -Infinity;
|
|
1451
|
+
for (const p of pixels) {
|
|
1452
|
+
const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
|
|
1453
|
+
if (rx < minX) minX = rx;
|
|
1454
|
+
if (rx > maxX) maxX = rx;
|
|
1455
|
+
if (ry < minY) minY = ry;
|
|
1456
|
+
if (ry > maxY) maxY = ry;
|
|
1439
1457
|
}
|
|
1458
|
+
const localCenterX = (minX + maxX) / 2;
|
|
1459
|
+
const localCenterY = (minY + maxY) / 2;
|
|
1460
|
+
const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
|
|
1461
|
+
const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
|
|
1462
|
+
const [ox, oy] = rotate(localCenterX, localCenterY, angle);
|
|
1463
|
+
return {
|
|
1464
|
+
center: [cx + ox, cy + oy],
|
|
1465
|
+
halfWidth,
|
|
1466
|
+
halfHeight,
|
|
1467
|
+
angle
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
|
|
1472
|
+
* bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
|
|
1473
|
+
* `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
|
|
1474
|
+
* and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
|
|
1475
|
+
* anchor is always the opposite corner, `(index + 2) % 4`.
|
|
1476
|
+
*/
|
|
1477
|
+
function boxCorners(box) {
|
|
1478
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1479
|
+
return [
|
|
1480
|
+
[-halfWidth, -halfHeight],
|
|
1481
|
+
[halfWidth, -halfHeight],
|
|
1482
|
+
[halfWidth, halfHeight],
|
|
1483
|
+
[-halfWidth, halfHeight]
|
|
1484
|
+
].map(([x, y]) => {
|
|
1485
|
+
const [rx, ry] = rotate(x, y, angle);
|
|
1486
|
+
return [center[0] + rx, center[1] + ry];
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
|
|
1491
|
+
* midpoint, along the box's local "up" direction, so the grip stays glued to
|
|
1492
|
+
* the box (rotates with it) rather than floating at a fixed screen offset.
|
|
1493
|
+
*/
|
|
1494
|
+
function rotateGripPixel(box) {
|
|
1495
|
+
const { center, halfHeight, angle } = box;
|
|
1496
|
+
const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
|
|
1497
|
+
return [center[0] + rx, center[1] + ry];
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Point-in-oriented-rectangle test, used to start a body (translate) drag
|
|
1501
|
+
* when the pointer goes down inside the box but not on a corner/rotate grip.
|
|
1502
|
+
* De-rotates `pixel` into the box's local frame about `box.center` and
|
|
1503
|
+
* compares against the half-extents.
|
|
1504
|
+
*/
|
|
1505
|
+
function pointInBox(box, pixel) {
|
|
1506
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1507
|
+
const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
|
|
1508
|
+
return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
|
|
1509
|
+
}
|
|
1510
|
+
function pixelDistance$1(a, b) {
|
|
1511
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Pick what part of the box, if any, `pixel` hits — corners first, then the
|
|
1515
|
+
* rotate grip, then plain containment (body drag). Corners and the rotate
|
|
1516
|
+
* grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
|
|
1517
|
+
* click just outside the box never starts a body drag. Returns `null` when
|
|
1518
|
+
* nothing matches.
|
|
1519
|
+
*/
|
|
1520
|
+
function pickBoxHandle(box, pixel, tolerance) {
|
|
1521
|
+
const corners = boxCorners(box);
|
|
1522
|
+
for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
|
|
1523
|
+
kind: "scale",
|
|
1524
|
+
index: i
|
|
1525
|
+
};
|
|
1526
|
+
if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
|
|
1527
|
+
if (pointInBox(box, pixel)) return { kind: "body" };
|
|
1440
1528
|
return null;
|
|
1441
1529
|
}
|
|
1442
1530
|
//#endregion
|
|
@@ -1452,17 +1540,8 @@ function translatePoints(base, dx, dy) {
|
|
|
1452
1540
|
return out;
|
|
1453
1541
|
});
|
|
1454
1542
|
}
|
|
1455
|
-
function
|
|
1456
|
-
return Math.
|
|
1457
|
-
}
|
|
1458
|
-
/**
|
|
1459
|
-
* Angle of `pixel` around the rotation pivot, measured in screen space so it
|
|
1460
|
-
* matches what the user sees and does. Falls back to lon/lat when the pivot
|
|
1461
|
-
* cannot be projected — exceedingly rare for an on-screen edit.
|
|
1462
|
-
*/
|
|
1463
|
-
function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
|
|
1464
|
-
if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
|
|
1465
|
-
return angleFromAnchor(pivot, projection.fromPixel(pixel));
|
|
1543
|
+
function pixelDistance(a, b) {
|
|
1544
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1466
1545
|
}
|
|
1467
1546
|
/**
|
|
1468
1547
|
* Rotate `points` by `angle` about the pivot in screen space — project each
|
|
@@ -1520,7 +1599,7 @@ function begun(kind, initialBase, move) {
|
|
|
1520
1599
|
* caller's working copy.
|
|
1521
1600
|
*/
|
|
1522
1601
|
function beginHandleDrag(args) {
|
|
1523
|
-
const { hit,
|
|
1602
|
+
const { hit, points, projection, rule } = args;
|
|
1524
1603
|
const { fromPixel } = projection;
|
|
1525
1604
|
const preDragPoints = clonePoints(points);
|
|
1526
1605
|
const snap = (next, activePointIndex) => rule ? rule.transform({
|
|
@@ -1530,45 +1609,103 @@ function beginHandleDrag(args) {
|
|
|
1530
1609
|
}) : next;
|
|
1531
1610
|
const dragOnePoint = (base, activeIndex) => {
|
|
1532
1611
|
const move = (pixel) => {
|
|
1533
|
-
return {
|
|
1534
|
-
points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
|
|
1535
|
-
rotateGripOverride: null
|
|
1536
|
-
};
|
|
1612
|
+
return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
|
|
1537
1613
|
};
|
|
1538
1614
|
return move;
|
|
1539
1615
|
};
|
|
1540
1616
|
if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1617
|
+
const insertIndex = hit.index + 1;
|
|
1618
|
+
const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
|
|
1619
|
+
const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
|
|
1620
|
+
const insertedBase = [
|
|
1621
|
+
...preDragPoints.slice(0, insertIndex),
|
|
1622
|
+
mid,
|
|
1623
|
+
...preDragPoints.slice(insertIndex)
|
|
1624
|
+
];
|
|
1625
|
+
return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
|
|
1626
|
+
}
|
|
1627
|
+
/** No corner drag may scale below this factor — guards against reflection/collapse through the anchor. */
|
|
1628
|
+
const MIN_SCALE_FACTOR = .05;
|
|
1629
|
+
/**
|
|
1630
|
+
* Begin a transform-box drag: uniform corner scale, box-center rotate, or
|
|
1631
|
+
* body translate. Mirrors `beginHandleDrag`'s shape (a `move`/`end` FSM seeded
|
|
1632
|
+
* from the pre-drag points) but keyed on `BoxDragKind` instead of `HandleKind`,
|
|
1633
|
+
* and additionally threading the box itself through so the caller can paint
|
|
1634
|
+
* the live box without re-fitting from projected geometry every frame.
|
|
1635
|
+
*/
|
|
1636
|
+
function beginBoxDrag(args) {
|
|
1637
|
+
const { kind, downPixel, points, projection, box, cornerIndex } = args;
|
|
1638
|
+
const { toPixel, fromPixel } = projection;
|
|
1639
|
+
const preDragPoints = clonePoints(points);
|
|
1640
|
+
if (kind === "scale") {
|
|
1641
|
+
const index = cornerIndex ?? 0;
|
|
1642
|
+
const anchor = boxCorners(box)[(index + 2) % 4];
|
|
1643
|
+
const downDist = pixelDistance(downPixel, anchor);
|
|
1644
|
+
const move = (pixel) => {
|
|
1645
|
+
const dist = pixelDistance(pixel, anchor);
|
|
1646
|
+
const rawFactor = downDist === 0 ? 1 : dist / downDist;
|
|
1647
|
+
const factor = Math.max(rawFactor, MIN_SCALE_FACTOR);
|
|
1648
|
+
return {
|
|
1649
|
+
points: preDragPoints.map((p) => {
|
|
1650
|
+
const px = toPixel(p);
|
|
1651
|
+
if (!px) return [...p];
|
|
1652
|
+
const ll = fromPixel([anchor[0] + (px[0] - anchor[0]) * factor, anchor[1] + (px[1] - anchor[1]) * factor]);
|
|
1653
|
+
const out = [ll[0], ll[1]];
|
|
1654
|
+
for (let k = 2; k < p.length; k++) out.push(p[k]);
|
|
1655
|
+
return out;
|
|
1656
|
+
}),
|
|
1657
|
+
box: {
|
|
1658
|
+
center: [anchor[0] + (box.center[0] - anchor[0]) * factor, anchor[1] + (box.center[1] - anchor[1]) * factor],
|
|
1659
|
+
halfWidth: box.halfWidth * factor,
|
|
1660
|
+
halfHeight: box.halfHeight * factor,
|
|
1661
|
+
angle: box.angle
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
};
|
|
1665
|
+
return {
|
|
1666
|
+
kind,
|
|
1667
|
+
move,
|
|
1668
|
+
end: (pixel) => move(pixel).points
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
if (kind === "rotate") {
|
|
1672
|
+
const pivotPixel = box.center;
|
|
1673
|
+
const pivot = fromPixel(pivotPixel);
|
|
1674
|
+
const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
|
|
1675
|
+
const move = (pixel) => {
|
|
1676
|
+
const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
|
|
1555
1677
|
return {
|
|
1556
|
-
points:
|
|
1557
|
-
|
|
1678
|
+
points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
|
|
1679
|
+
box: {
|
|
1680
|
+
center: [...pivotPixel],
|
|
1681
|
+
halfWidth: box.halfWidth,
|
|
1682
|
+
halfHeight: box.halfHeight,
|
|
1683
|
+
angle: box.angle + angle
|
|
1684
|
+
}
|
|
1558
1685
|
};
|
|
1559
|
-
}
|
|
1686
|
+
};
|
|
1687
|
+
return {
|
|
1688
|
+
kind,
|
|
1689
|
+
move,
|
|
1690
|
+
end: (pixel) => move(pixel).points
|
|
1691
|
+
};
|
|
1560
1692
|
}
|
|
1561
|
-
const
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1564
|
-
const startGrip = hit.position;
|
|
1565
|
-
return begun("rotate", preDragPoints, (pixel) => {
|
|
1566
|
-
const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
|
|
1693
|
+
const origin = fromPixel(downPixel);
|
|
1694
|
+
const move = (pixel) => {
|
|
1695
|
+
const cur = fromPixel(pixel);
|
|
1567
1696
|
return {
|
|
1568
|
-
points:
|
|
1569
|
-
|
|
1697
|
+
points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
|
|
1698
|
+
box: {
|
|
1699
|
+
...box,
|
|
1700
|
+
center: [box.center[0] + (pixel[0] - downPixel[0]), box.center[1] + (pixel[1] - downPixel[1])]
|
|
1701
|
+
}
|
|
1570
1702
|
};
|
|
1571
|
-
}
|
|
1703
|
+
};
|
|
1704
|
+
return {
|
|
1705
|
+
kind,
|
|
1706
|
+
move,
|
|
1707
|
+
end: (pixel) => move(pixel).points
|
|
1708
|
+
};
|
|
1572
1709
|
}
|
|
1573
1710
|
//#endregion
|
|
1574
1711
|
//#region src/tactical-draw/edit-session.ts
|
|
@@ -1590,8 +1727,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
|
|
|
1590
1727
|
const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
|
|
1591
1728
|
/**
|
|
1592
1729
|
* Internal test seam — simulate a completed rotate gesture by passing an angle
|
|
1593
|
-
* in radians. Every control point rotates around the
|
|
1594
|
-
*
|
|
1730
|
+
* in radians. Every control point rotates around the transform box's center
|
|
1731
|
+
* (falling back to the control-points' bounding-box center when no box has
|
|
1732
|
+
* been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
|
|
1733
|
+
* the same delta a live rotate box-drag would (ADR-0017).
|
|
1595
1734
|
*
|
|
1596
1735
|
* @internal
|
|
1597
1736
|
*/
|
|
@@ -1612,35 +1751,112 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
|
|
|
1612
1751
|
*/
|
|
1613
1752
|
const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
1614
1753
|
//#endregion
|
|
1615
|
-
//#region src/tactical-draw/
|
|
1754
|
+
//#region src/tactical-draw/box-features.ts
|
|
1616
1755
|
/**
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1756
|
+
* Rendering helpers shared by the single-measure edit controller
|
|
1757
|
+
* (`edit-controller.ts`) and the group transform controller
|
|
1758
|
+
* (`transform-controller.ts`): both paint the same `TransformBox` shape —
|
|
1759
|
+
* corner scale handles, a rotate grip, and a closed-ring outline — so the
|
|
1760
|
+
* projection from pure box geometry (`transform-box.ts`) to lonLat `Handle`s
|
|
1761
|
+
* / GeoJSON features lives in one place instead of two copies drifting apart.
|
|
1762
|
+
* See ADR-0017.
|
|
1622
1763
|
*/
|
|
1623
|
-
|
|
1624
|
-
|
|
1764
|
+
/** Walk a GeoJSON `Geometry`'s coordinate arrays, pushing every `Position` onto `out`. */
|
|
1765
|
+
function collectFeaturePositions(geometry, out) {
|
|
1766
|
+
switch (geometry.type) {
|
|
1767
|
+
case "Point":
|
|
1768
|
+
out.push(geometry.coordinates);
|
|
1769
|
+
break;
|
|
1770
|
+
case "MultiPoint":
|
|
1771
|
+
case "LineString":
|
|
1772
|
+
for (const p of geometry.coordinates) out.push(p);
|
|
1773
|
+
break;
|
|
1774
|
+
case "MultiLineString":
|
|
1775
|
+
case "Polygon":
|
|
1776
|
+
for (const ring of geometry.coordinates) for (const p of ring) out.push(p);
|
|
1777
|
+
break;
|
|
1778
|
+
case "MultiPolygon":
|
|
1779
|
+
for (const poly of geometry.coordinates) for (const ring of poly) for (const p of ring) out.push(p);
|
|
1780
|
+
break;
|
|
1781
|
+
case "GeometryCollection":
|
|
1782
|
+
for (const g of geometry.geometries) collectFeaturePositions(g, out);
|
|
1783
|
+
break;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Project a `TransformBox`'s 4 corner scale handles and rotate grip into
|
|
1788
|
+
* lonLat `Handle`s via `fromPixel` — the render projection of the box's
|
|
1789
|
+
* pixel-space geometry (`boxCorners` / `rotateGripPixel`), reused by both
|
|
1790
|
+
* controllers so box-handle placement can't drift between single-measure and
|
|
1791
|
+
* group transforms.
|
|
1792
|
+
*/
|
|
1793
|
+
function boxToHandles(box, fromPixel) {
|
|
1794
|
+
const handles = boxCorners(box).map((corner, index) => ({
|
|
1795
|
+
kind: "scale",
|
|
1796
|
+
index,
|
|
1797
|
+
position: fromPixel(corner)
|
|
1798
|
+
}));
|
|
1799
|
+
handles.push({
|
|
1800
|
+
kind: "rotate",
|
|
1801
|
+
index: 0,
|
|
1802
|
+
position: fromPixel(rotateGripPixel(box))
|
|
1803
|
+
});
|
|
1804
|
+
return handles;
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Build the closed `LineString` outline feature for `box`, styled from
|
|
1808
|
+
* `style` (delivered per-feature via `properties.style`, as the adapters
|
|
1809
|
+
* already honor for line features). `featureId` is the caller's stable id —
|
|
1810
|
+
* `${prefix}:box-outline` for a single-measure or group box, or
|
|
1811
|
+
* `${prefix}:member-outline:{measureId}` for a group member's own outline.
|
|
1812
|
+
*/
|
|
1813
|
+
function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
1814
|
+
const corners = boxCorners(box).map((c) => fromPixel(c));
|
|
1815
|
+
return {
|
|
1816
|
+
type: "Feature",
|
|
1817
|
+
id: featureId,
|
|
1818
|
+
geometry: {
|
|
1819
|
+
type: "LineString",
|
|
1820
|
+
coordinates: [...corners, corners[0]]
|
|
1821
|
+
},
|
|
1822
|
+
properties: style ? { style: { ...style } } : {}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
//#endregion
|
|
1826
|
+
//#region src/tactical-draw/edit-controller.ts
|
|
1827
|
+
/**
|
|
1828
|
+
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
1829
|
+
* considered equivalent to the edit-start input when every control-point
|
|
1830
|
+
* component agrees within this threshold. PRD #40 calls out tolerance-based
|
|
1831
|
+
* dirty tracking; the constant lives in one place so future slices can lift
|
|
1832
|
+
* it to an option without touching call sites.
|
|
1833
|
+
*/
|
|
1834
|
+
const DIRTY_EPSILON$1 = 1e-9;
|
|
1835
|
+
const HANDLE_HIT_TOLERANCE_PX$1 = 12;
|
|
1836
|
+
/**
|
|
1837
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
1838
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
1839
|
+
* click, not a gesture: it is not committed (geometry restored, no
|
|
1840
|
+
* `EditChangeEvent`, `dirty` untouched) but the click-away listener is
|
|
1841
|
+
* suppressed so the click doesn't close the edit. Corner/rotate drags commit
|
|
1842
|
+
* even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
|
|
1843
|
+
*/
|
|
1844
|
+
const CLICK_SLOP_PX$1 = 3;
|
|
1625
1845
|
const ALLOWED_EDIT_MODES = new Set([
|
|
1626
1846
|
"reshape",
|
|
1627
|
-
"
|
|
1628
|
-
"rotate",
|
|
1847
|
+
"transform",
|
|
1629
1848
|
"delete"
|
|
1630
1849
|
]);
|
|
1631
1850
|
/**
|
|
1632
|
-
* Drive an interactive edit against a `MapAdapter`. Implements the
|
|
1633
|
-
*
|
|
1851
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
1852
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
1634
1853
|
*
|
|
1635
1854
|
* - lift-original (remove from graphics layer) + working copy
|
|
1636
|
-
* - preview + handle rendering
|
|
1855
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
1856
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
1637
1857
|
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
1638
1858
|
* - default click-away close policy
|
|
1639
1859
|
* - close / abort / signal / preempt / destroy paths
|
|
1640
|
-
*
|
|
1641
|
-
* Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
|
|
1642
|
-
* gesture binding lands with the change-event spec in #49 and the
|
|
1643
|
-
* translate/rotate handle set in #50.
|
|
1644
1860
|
*/
|
|
1645
1861
|
function runEdit(opts) {
|
|
1646
1862
|
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
@@ -1650,6 +1866,7 @@ function runEdit(opts) {
|
|
|
1650
1866
|
const rule = metadata.rule;
|
|
1651
1867
|
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
1652
1868
|
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
1869
|
+
const closedRing = rule?.closedRing ?? false;
|
|
1653
1870
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
1654
1871
|
const isAreaKind = metadata.geometry === "area";
|
|
1655
1872
|
const requestedModes = normaliseModes(initialModes);
|
|
@@ -1680,12 +1897,55 @@ function runEdit(opts) {
|
|
|
1680
1897
|
*/
|
|
1681
1898
|
let pendingModes = null;
|
|
1682
1899
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
1900
|
+
/**
|
|
1901
|
+
* The transform box's rotation, in radians, session-scoped (ADR-0017 decision
|
|
1902
|
+
* #2): starts at `0` (axis-aligned) and accumulates every committed rotate
|
|
1903
|
+
* gesture's delta, so the box stays glued to the shape across gestures. Never
|
|
1904
|
+
* persisted on the measure; a fresh edit always starts back at `0`.
|
|
1905
|
+
*/
|
|
1906
|
+
let boxAngle = 0;
|
|
1907
|
+
/**
|
|
1908
|
+
* The transform box fitted around the *rendered* working-copy geometry
|
|
1909
|
+
* (pixel space), or `null` when nothing projected this frame. Re-fitted
|
|
1910
|
+
* after every committed gesture, every reshape-drag paint, and on
|
|
1911
|
+
* `onViewChange`; painted rigidly from the live drag's own snapshot instead
|
|
1912
|
+
* while a box gesture (`activeBoxDrag`) is in flight.
|
|
1913
|
+
*/
|
|
1914
|
+
let box = null;
|
|
1915
|
+
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
1916
|
+
let lastPreviewFeatures = [];
|
|
1683
1917
|
let ctx;
|
|
1684
1918
|
let driver;
|
|
1685
1919
|
const dragProjection = {
|
|
1686
1920
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
1687
1921
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
1688
1922
|
};
|
|
1923
|
+
/**
|
|
1924
|
+
* Fit the transform box around `features`' rendered geometry: every
|
|
1925
|
+
* coordinate is projected to pixels via the pointer driver and
|
|
1926
|
+
* `fitTransformBox`'d at the current `boxAngle`. `null` when nothing
|
|
1927
|
+
* projects (e.g. the working copy renders no features this frame).
|
|
1928
|
+
*/
|
|
1929
|
+
function fitBoxFromFeatures(features) {
|
|
1930
|
+
const positions = [];
|
|
1931
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
1932
|
+
const pixels = [];
|
|
1933
|
+
for (const p of positions) {
|
|
1934
|
+
const px = driver.toPixel(p);
|
|
1935
|
+
if (px) pixels.push(px);
|
|
1936
|
+
}
|
|
1937
|
+
return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
|
|
1941
|
+
* in flight — that drag paints the box rigidly from its own snapshot
|
|
1942
|
+
* (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
|
|
1943
|
+
* doesn't fight its own paint.
|
|
1944
|
+
*/
|
|
1945
|
+
function refitBox() {
|
|
1946
|
+
if (activeBoxDrag) return;
|
|
1947
|
+
box = fitBoxFromFeatures(lastPreviewFeatures);
|
|
1948
|
+
}
|
|
1689
1949
|
function liftOriginalFromGraphics() {
|
|
1690
1950
|
if (!wasOnGraphicsLayer) return;
|
|
1691
1951
|
const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
@@ -1706,37 +1966,48 @@ function runEdit(opts) {
|
|
|
1706
1966
|
features = [];
|
|
1707
1967
|
}
|
|
1708
1968
|
ctx.writeLayer(previewLayer, features);
|
|
1709
|
-
|
|
1969
|
+
lastPreviewFeatures = features;
|
|
1970
|
+
refitBox();
|
|
1971
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
|
|
1972
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
|
|
1710
1973
|
ctx.writeLayer(guideLayer, guideFeatures);
|
|
1711
1974
|
}
|
|
1712
1975
|
function rescaleOnViewChange() {
|
|
1713
|
-
if (
|
|
1976
|
+
if (ctx.settled) return;
|
|
1977
|
+
if (measureUsesAdapterResolution({
|
|
1714
1978
|
kind: measure.kind,
|
|
1715
1979
|
options: workingOptions
|
|
1716
|
-
}))
|
|
1717
|
-
|
|
1980
|
+
})) renderPreview();
|
|
1981
|
+
else refitBox();
|
|
1982
|
+
renderHandlesLayer();
|
|
1718
1983
|
}
|
|
1719
1984
|
function renderHandlesLayer() {
|
|
1720
1985
|
if (ctx.settled) return;
|
|
1721
|
-
|
|
1986
|
+
const features = toFeatures(buildHandleSet(workingPoints, modes, {
|
|
1722
1987
|
includeMidpoints: midpointHandlesEnabled,
|
|
1723
|
-
trailingFixedSlots
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1988
|
+
trailingFixedSlots,
|
|
1989
|
+
closedRing
|
|
1990
|
+
}), handleIdPrefix, interactionStyle);
|
|
1991
|
+
if (modes.includes("transform")) {
|
|
1992
|
+
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
1993
|
+
if (liveBox) {
|
|
1994
|
+
features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
|
|
1995
|
+
features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1733
1998
|
ctx.writeLayer(handlesLayer, features);
|
|
1734
1999
|
}
|
|
1735
|
-
|
|
2000
|
+
/**
|
|
2001
|
+
* Builds the committed snapshot against explicit `options` rather than the
|
|
2002
|
+
* live `workingOptions` — used to render a gesture's "previous" snapshot
|
|
2003
|
+
* against pre-freeze options when a rotate has already merged its
|
|
2004
|
+
* orientation freeze into `workingOptions` by the time the commit runs.
|
|
2005
|
+
*/
|
|
2006
|
+
function rebuildRenderedWith(options) {
|
|
1736
2007
|
const working = {
|
|
1737
2008
|
...measure,
|
|
1738
2009
|
style: currentStyle,
|
|
1739
|
-
options
|
|
2010
|
+
options,
|
|
1740
2011
|
controlPoints: workingPoints.map((p) => [...p])
|
|
1741
2012
|
};
|
|
1742
2013
|
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
@@ -1745,8 +2016,11 @@ function runEdit(opts) {
|
|
|
1745
2016
|
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
1746
2017
|
};
|
|
1747
2018
|
}
|
|
2019
|
+
function rebuildRendered() {
|
|
2020
|
+
return rebuildRenderedWith(workingOptions);
|
|
2021
|
+
}
|
|
1748
2022
|
function recomputeDirty() {
|
|
1749
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
|
|
2023
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
|
|
1750
2024
|
}
|
|
1751
2025
|
function replaceWorkingPoints(next) {
|
|
1752
2026
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -1755,6 +2029,23 @@ function runEdit(opts) {
|
|
|
1755
2029
|
workingOptions = { ...next ?? {} };
|
|
1756
2030
|
}
|
|
1757
2031
|
/**
|
|
2032
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
2033
|
+
* anchor) to their current concrete values, merging any patch onto
|
|
2034
|
+
* `workingOptions` in place. Called the moment a rotate gesture starts (a
|
|
2035
|
+
* box-drag rotate hit, or the `commitRotate` test seam) — a rigid rotation
|
|
2036
|
+
* of `workingPoints` follows immediately after, so the freeze must land
|
|
2037
|
+
* first or the orientation-relative default would re-derive against the
|
|
2038
|
+
* rotated geometry (ADR-0023). No dirty/change-event side effects of its
|
|
2039
|
+
* own — the rotate gesture's own commit carries the frozen options.
|
|
2040
|
+
*/
|
|
2041
|
+
function freezeOrientationForRotate() {
|
|
2042
|
+
const patch = freezeOrientationOptions(measure.kind, workingPoints.map((p) => [...p]), workingOptions);
|
|
2043
|
+
if (patch) workingOptions = {
|
|
2044
|
+
...workingOptions ?? {},
|
|
2045
|
+
...patch
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
1758
2049
|
* Emit a completed gesture to every registered listener. The pre-gesture
|
|
1759
2050
|
* snapshot is provided as `previousRendered`; `commitGesture` has already
|
|
1760
2051
|
* mutated `workingPoints` and re-rendered preview / handle layers before
|
|
@@ -1820,19 +2111,20 @@ function runEdit(opts) {
|
|
|
1820
2111
|
* queued during the drag are applied after the listener emission (so the
|
|
1821
2112
|
* post-gesture render reflects the requested mode set).
|
|
1822
2113
|
*/
|
|
1823
|
-
function commitGesture(nextPoints) {
|
|
2114
|
+
function commitGesture(nextPoints, preGestureOptions) {
|
|
1824
2115
|
if (ctx.settled) return;
|
|
1825
2116
|
const previousPoints = workingPoints.map((p) => [...p]);
|
|
1826
|
-
const previousOptions = { ...workingOptions ?? {} };
|
|
2117
|
+
const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
|
|
1827
2118
|
const previousStyle = currentStyle;
|
|
1828
2119
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
1829
|
-
const previousRendered =
|
|
2120
|
+
const previousRendered = rebuildRenderedWith(previousOptions);
|
|
1830
2121
|
replaceWorkingPoints(nextPoints);
|
|
1831
2122
|
let currentRendered;
|
|
1832
2123
|
try {
|
|
1833
2124
|
currentRendered = rebuildRendered();
|
|
1834
2125
|
} catch (err) {
|
|
1835
2126
|
replaceWorkingPoints(previousPoints);
|
|
2127
|
+
if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
|
|
1836
2128
|
dragInFlight = false;
|
|
1837
2129
|
drainPendingModes();
|
|
1838
2130
|
throw err;
|
|
@@ -1922,16 +2214,23 @@ function runEdit(opts) {
|
|
|
1922
2214
|
}));
|
|
1923
2215
|
}
|
|
1924
2216
|
/**
|
|
1925
|
-
*
|
|
1926
|
-
* around the
|
|
2217
|
+
* Test seam for a rotate gesture: rotate every working point by `angle`
|
|
2218
|
+
* (radians) around the transform box's center — falling back to the
|
|
2219
|
+
* control-points' bounding-box center when no box has been fitted yet (e.g.
|
|
2220
|
+
* before the first paint). The rotation runs in screen space (see
|
|
1927
2221
|
* `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
|
|
1928
|
-
* latitude
|
|
2222
|
+
* latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
|
|
2223
|
+
* would (ADR-0017), so the box stays glued to the shape across seam calls.
|
|
1929
2224
|
*/
|
|
1930
2225
|
function commitRotate(angle) {
|
|
1931
2226
|
if (ctx.settled) return;
|
|
1932
|
-
const
|
|
2227
|
+
const preFreezeOptions = { ...workingOptions ?? {} };
|
|
2228
|
+
freezeOrientationForRotate();
|
|
2229
|
+
const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
|
|
2230
|
+
const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
|
|
1933
2231
|
if (!pivot) return;
|
|
1934
|
-
|
|
2232
|
+
boxAngle += angle;
|
|
2233
|
+
commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
|
|
1935
2234
|
}
|
|
1936
2235
|
function modesEqual(a, b) {
|
|
1937
2236
|
if (a.length !== b.length) return false;
|
|
@@ -1957,44 +2256,894 @@ function runEdit(opts) {
|
|
|
1957
2256
|
get options() {
|
|
1958
2257
|
return { ...workingOptions ?? {} };
|
|
1959
2258
|
},
|
|
1960
|
-
get style() {
|
|
1961
|
-
return currentStyle ? { ...currentStyle } : void 0;
|
|
2259
|
+
get style() {
|
|
2260
|
+
return currentStyle ? { ...currentStyle } : void 0;
|
|
2261
|
+
},
|
|
2262
|
+
get dirty() {
|
|
2263
|
+
return dirty;
|
|
2264
|
+
},
|
|
2265
|
+
get modes() {
|
|
2266
|
+
return modes;
|
|
2267
|
+
},
|
|
2268
|
+
get sizeAnchor() {
|
|
2269
|
+
return sizeAnchor;
|
|
2270
|
+
},
|
|
2271
|
+
close() {
|
|
2272
|
+
return ctx.commit(rebuildRendered);
|
|
2273
|
+
},
|
|
2274
|
+
abort() {
|
|
2275
|
+
if (ctx.settled) return;
|
|
2276
|
+
ctx.fail(new TacticalDrawAbortError("session"));
|
|
2277
|
+
},
|
|
2278
|
+
setModes(next) {
|
|
2279
|
+
if (ctx.settled) return;
|
|
2280
|
+
const normalised = normaliseModes(next);
|
|
2281
|
+
if (dragInFlight) {
|
|
2282
|
+
pendingModes = normalised;
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
applyModes(normalised);
|
|
2286
|
+
},
|
|
2287
|
+
setOptions(partial) {
|
|
2288
|
+
setOptions(partial);
|
|
2289
|
+
},
|
|
2290
|
+
setStyle(partial) {
|
|
2291
|
+
setStyle(partial);
|
|
2292
|
+
},
|
|
2293
|
+
setSizeAnchor(next) {
|
|
2294
|
+
setSizeAnchor(next);
|
|
2295
|
+
},
|
|
2296
|
+
onChange(listener) {
|
|
2297
|
+
if (ctx.settled) return () => {};
|
|
2298
|
+
changeListeners.add(listener);
|
|
2299
|
+
return () => {
|
|
2300
|
+
changeListeners.delete(listener);
|
|
2301
|
+
};
|
|
2302
|
+
},
|
|
2303
|
+
[COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
|
|
2304
|
+
commitGesture(nextPoints);
|
|
2305
|
+
},
|
|
2306
|
+
[COMMIT_TRANSLATE_FOR_TEST]: (delta) => {
|
|
2307
|
+
commitTranslate(delta);
|
|
2308
|
+
},
|
|
2309
|
+
[COMMIT_ROTATE_FOR_TEST]: (angle) => {
|
|
2310
|
+
commitRotate(angle);
|
|
2311
|
+
},
|
|
2312
|
+
[BEGIN_DRAG_FOR_TEST]: () => {
|
|
2313
|
+
if (ctx.settled) return;
|
|
2314
|
+
dragInFlight = true;
|
|
2315
|
+
},
|
|
2316
|
+
[END_DRAG_FOR_TEST]: () => {
|
|
2317
|
+
if (ctx.settled) return;
|
|
2318
|
+
dragInFlight = false;
|
|
2319
|
+
drainPendingModes();
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
let activeDrag = null;
|
|
2323
|
+
/**
|
|
2324
|
+
* A live transform-box gesture (corner scale, box rotate, or body
|
|
2325
|
+
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
2326
|
+
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
2327
|
+
* `lastSnapshot.box` instead of re-fitting while this is non-null, and
|
|
2328
|
+
* `preDragBox` lets a committed rotate compute the angle delta to fold into
|
|
2329
|
+
* `boxAngle`.
|
|
2330
|
+
*/
|
|
2331
|
+
let activeBoxDrag = null;
|
|
2332
|
+
/**
|
|
2333
|
+
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2334
|
+
* exclusive `delete` mode). Deletion is
|
|
2335
|
+
* confined to variable-length measures (the same condition that enables
|
|
2336
|
+
* midpoint inserts — see ADR-0005) and is denied when it would:
|
|
2337
|
+
* - target a trailing fixed slot (e.g. Axis1's width handle), or
|
|
2338
|
+
* - drop the user-spine point count below the rule's `minimumUserPoints`.
|
|
2339
|
+
* Fixed-count measures re-pad on derive, so deleting a vertex there is moot.
|
|
2340
|
+
*/
|
|
2341
|
+
function canDeleteVertex(index) {
|
|
2342
|
+
if (!midpointHandlesEnabled) return false;
|
|
2343
|
+
if (!modes.includes("reshape") && !modes.includes("delete")) return false;
|
|
2344
|
+
const spineCount = workingPoints.length - trailingFixedSlots;
|
|
2345
|
+
if (index < 0 || index >= spineCount) return false;
|
|
2346
|
+
const minimum = rule?.minimumUserPoints ?? (isAreaKind ? 3 : 2);
|
|
2347
|
+
return spineCount - 1 >= minimum;
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Remove the vertex at `index` and commit through the normal gesture path so
|
|
2351
|
+
* a single `EditChangeEvent` covers the delete and dirty tracking stays
|
|
2352
|
+
* consistent. The rule's `transform` runs with the removed index as the
|
|
2353
|
+
* active point so frame-relative invariants survive the delete: for Axis1
|
|
2354
|
+
* this re-projects the trailing width handle into the new tip→neck frame,
|
|
2355
|
+
* keeping the main-attack arrow's width unchanged even when the tip or neck
|
|
2356
|
+
* is removed. Rules without that concern (Line1) pass the points through.
|
|
2357
|
+
*/
|
|
2358
|
+
function deleteVertex(index) {
|
|
2359
|
+
if (ctx.settled) return;
|
|
2360
|
+
const previous = workingPoints.map((p) => [...p]);
|
|
2361
|
+
const spliced = previous.filter((_, i) => i !== index);
|
|
2362
|
+
commitGesture(rule ? rule.transform({
|
|
2363
|
+
previous,
|
|
2364
|
+
next: spliced,
|
|
2365
|
+
activePointIndex: index
|
|
2366
|
+
}) : spliced);
|
|
2367
|
+
}
|
|
2368
|
+
const onDriverDown = (pixel, info) => {
|
|
2369
|
+
if (ctx.settled || dragInFlight) return;
|
|
2370
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
|
|
2371
|
+
const hit = pickHandle(buildHandleSet(workingPoints, modes, {
|
|
2372
|
+
includeMidpoints: midpointHandlesEnabled,
|
|
2373
|
+
trailingFixedSlots,
|
|
2374
|
+
closedRing
|
|
2375
|
+
}), pixel, tol, (lonLat) => driver.toPixel(lonLat));
|
|
2376
|
+
if (hit) {
|
|
2377
|
+
if (modes.includes("delete")) {
|
|
2378
|
+
if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
|
|
2379
|
+
deleteVertex(hit.index);
|
|
2380
|
+
suppressClickAway = true;
|
|
2381
|
+
}
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
2385
|
+
deleteVertex(hit.index);
|
|
2386
|
+
suppressClickAway = true;
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2390
|
+
const { drag: fsm, initialPoints } = beginHandleDrag({
|
|
2391
|
+
hit,
|
|
2392
|
+
downPixel: pixel,
|
|
2393
|
+
points: preDragPoints,
|
|
2394
|
+
projection: dragProjection,
|
|
2395
|
+
rule
|
|
2396
|
+
});
|
|
2397
|
+
activeDrag = {
|
|
2398
|
+
fsm,
|
|
2399
|
+
preDragPoints
|
|
2400
|
+
};
|
|
2401
|
+
if (fsm.kind === "midpoint") {
|
|
2402
|
+
workingPoints.splice(0, workingPoints.length, ...initialPoints);
|
|
2403
|
+
renderPreview();
|
|
2404
|
+
renderHandlesLayer();
|
|
2405
|
+
}
|
|
2406
|
+
dragInFlight = true;
|
|
2407
|
+
driver.setDragPanEnabled(false);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
if (modes.includes("transform") && box) {
|
|
2411
|
+
const boxHit = pickBoxHandle(box, pixel, tol);
|
|
2412
|
+
if (boxHit) {
|
|
2413
|
+
const preDragOptions = { ...workingOptions ?? {} };
|
|
2414
|
+
if (boxHit.kind === "rotate") {
|
|
2415
|
+
freezeOrientationForRotate();
|
|
2416
|
+
renderPreview();
|
|
2417
|
+
renderHandlesLayer();
|
|
2418
|
+
}
|
|
2419
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2420
|
+
activeBoxDrag = {
|
|
2421
|
+
fsm: beginBoxDrag({
|
|
2422
|
+
kind: boxHit.kind,
|
|
2423
|
+
downPixel: pixel,
|
|
2424
|
+
points: preDragPoints,
|
|
2425
|
+
projection: dragProjection,
|
|
2426
|
+
box,
|
|
2427
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
2428
|
+
}),
|
|
2429
|
+
preDragPoints,
|
|
2430
|
+
preDragBox: box,
|
|
2431
|
+
downPixel: pixel,
|
|
2432
|
+
lastSnapshot: {
|
|
2433
|
+
points: preDragPoints,
|
|
2434
|
+
box
|
|
2435
|
+
},
|
|
2436
|
+
preDragOptions
|
|
2437
|
+
};
|
|
2438
|
+
dragInFlight = true;
|
|
2439
|
+
driver.setDragPanEnabled(false);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
function paintDragSnapshot(pixel) {
|
|
2444
|
+
if (!activeDrag) return;
|
|
2445
|
+
const snapshot = activeDrag.fsm.move(pixel);
|
|
2446
|
+
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2447
|
+
renderPreview();
|
|
2448
|
+
renderHandlesLayer();
|
|
2449
|
+
recomputeDirty();
|
|
2450
|
+
}
|
|
2451
|
+
function paintBoxDragSnapshot(pixel) {
|
|
2452
|
+
if (!activeBoxDrag) return;
|
|
2453
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
2454
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
2455
|
+
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2456
|
+
renderPreview();
|
|
2457
|
+
renderHandlesLayer();
|
|
2458
|
+
recomputeDirty();
|
|
2459
|
+
}
|
|
2460
|
+
const onDriverMove = (pixel) => {
|
|
2461
|
+
if (ctx.settled) return;
|
|
2462
|
+
if (activeDrag) paintDragSnapshot(pixel);
|
|
2463
|
+
else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
2464
|
+
};
|
|
2465
|
+
/** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
|
|
2466
|
+
function endDragBookkeeping() {
|
|
2467
|
+
driver.setDragPanEnabled(true);
|
|
2468
|
+
dragInFlight = false;
|
|
2469
|
+
drainPendingModes();
|
|
2470
|
+
}
|
|
2471
|
+
const onDriverUp = (pixel) => {
|
|
2472
|
+
if (activeDrag) {
|
|
2473
|
+
if (ctx.settled) {
|
|
2474
|
+
activeDrag = null;
|
|
2475
|
+
dragInFlight = false;
|
|
2476
|
+
driver.setDragPanEnabled(true);
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
const { fsm, preDragPoints } = activeDrag;
|
|
2480
|
+
activeDrag = null;
|
|
2481
|
+
driver.setDragPanEnabled(true);
|
|
2482
|
+
const next = fsm.end(pixel);
|
|
2483
|
+
replaceWorkingPoints(preDragPoints);
|
|
2484
|
+
commitGesture(next);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (activeBoxDrag) {
|
|
2488
|
+
if (ctx.settled) {
|
|
2489
|
+
activeBoxDrag = null;
|
|
2490
|
+
dragInFlight = false;
|
|
2491
|
+
driver.setDragPanEnabled(true);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
|
|
2495
|
+
activeBoxDrag = null;
|
|
2496
|
+
driver.setDragPanEnabled(true);
|
|
2497
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
2498
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
|
|
2499
|
+
replaceWorkingPoints(preDragPoints);
|
|
2500
|
+
renderPreview();
|
|
2501
|
+
renderHandlesLayer();
|
|
2502
|
+
recomputeDirty();
|
|
2503
|
+
endDragBookkeeping();
|
|
2504
|
+
suppressClickAway = true;
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
const finalSnapshot = fsm.move(pixel);
|
|
2508
|
+
replaceWorkingPoints(preDragPoints);
|
|
2509
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
2510
|
+
commitGesture(finalSnapshot.points, preDragOptions);
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
let pickPreview;
|
|
2515
|
+
let pickHandles;
|
|
2516
|
+
let pickGraphics;
|
|
2517
|
+
const handlePickPreview = (e) => {
|
|
2518
|
+
pickPreview = e;
|
|
2519
|
+
};
|
|
2520
|
+
const handlePickHandles = (e) => {
|
|
2521
|
+
pickHandles = e;
|
|
2522
|
+
};
|
|
2523
|
+
const handlePickGraphics = (e) => {
|
|
2524
|
+
pickGraphics = e;
|
|
2525
|
+
};
|
|
2526
|
+
const handleClick = () => {
|
|
2527
|
+
const localPreview = pickPreview;
|
|
2528
|
+
const localHandles = pickHandles;
|
|
2529
|
+
const localGraphics = pickGraphics;
|
|
2530
|
+
pickPreview = void 0;
|
|
2531
|
+
pickHandles = void 0;
|
|
2532
|
+
pickGraphics = void 0;
|
|
2533
|
+
if (ctx.settled) return;
|
|
2534
|
+
if (!closeOnClickAway) return;
|
|
2535
|
+
if (suppressClickAway) {
|
|
2536
|
+
suppressClickAway = false;
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if (localPreview || localHandles) return;
|
|
2540
|
+
if (localGraphics && localGraphics.id !== measure.id) {
|
|
2541
|
+
ctx.commit(rebuildRendered);
|
|
2542
|
+
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
ctx.commit(rebuildRendered);
|
|
2546
|
+
};
|
|
2547
|
+
const core = runInteraction({
|
|
2548
|
+
adapter,
|
|
2549
|
+
signal,
|
|
2550
|
+
layers: [
|
|
2551
|
+
previewLayer,
|
|
2552
|
+
guideLayer,
|
|
2553
|
+
handlesLayer
|
|
2554
|
+
],
|
|
2555
|
+
onViewChange: rescaleOnViewChange,
|
|
2556
|
+
onSettled,
|
|
2557
|
+
attach(c) {
|
|
2558
|
+
ctx = c;
|
|
2559
|
+
driver = adapter.createEditPointerDriver();
|
|
2560
|
+
liftOriginalFromGraphics();
|
|
2561
|
+
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
2562
|
+
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
2563
|
+
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
2564
|
+
const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
|
|
2565
|
+
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
2566
|
+
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
2567
|
+
adapter.on("click", handleClick);
|
|
2568
|
+
c.onTeardown(() => {
|
|
2569
|
+
adapter.off("click", handleClick);
|
|
2570
|
+
unsubPickPreview();
|
|
2571
|
+
unsubPickHandles();
|
|
2572
|
+
unsubPickGraphics();
|
|
2573
|
+
unsubDown();
|
|
2574
|
+
unsubMove();
|
|
2575
|
+
unsubUp();
|
|
2576
|
+
if (activeDrag !== null || activeBoxDrag !== null) {
|
|
2577
|
+
driver.setDragPanEnabled(true);
|
|
2578
|
+
activeDrag = null;
|
|
2579
|
+
activeBoxDrag = null;
|
|
2580
|
+
dragInFlight = false;
|
|
2581
|
+
}
|
|
2582
|
+
driver.dispose();
|
|
2583
|
+
}, wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
|
|
2584
|
+
renderPreview();
|
|
2585
|
+
renderHandlesLayer();
|
|
2586
|
+
recomputeDirty();
|
|
2587
|
+
if (onSession) onSession(session);
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
return {
|
|
2591
|
+
promise: core.promise,
|
|
2592
|
+
get settled() {
|
|
2593
|
+
return core.settled;
|
|
2594
|
+
},
|
|
2595
|
+
measureId: measure.id,
|
|
2596
|
+
abort(reason, cause) {
|
|
2597
|
+
core.abort(reason, cause);
|
|
2598
|
+
},
|
|
2599
|
+
notifyExternalMeasure(next) {
|
|
2600
|
+
if (core.settled) return;
|
|
2601
|
+
if (stylesEqual(currentStyle, next.style)) return;
|
|
2602
|
+
currentStyle = next.style;
|
|
2603
|
+
renderPreview();
|
|
2604
|
+
}
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
|
|
2609
|
+
* throws `TypeError` synchronously for an unknown mode, collapses `delete` to
|
|
2610
|
+
* an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
|
|
2611
|
+
* Exported so the façade can hoist this validation *before* `guardAndPreempt`
|
|
2612
|
+
* — an invalid mode must not tear down whatever edit/draw is already active
|
|
2613
|
+
* (see the `td.edit` regression test for issue tracked alongside ADR-0017).
|
|
2614
|
+
*/
|
|
2615
|
+
function normaliseModes(modes) {
|
|
2616
|
+
if (!modes || modes.length === 0) return ["reshape", "transform"];
|
|
2617
|
+
for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2618
|
+
if (modes.includes("delete")) return ["delete"];
|
|
2619
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2620
|
+
const out = [];
|
|
2621
|
+
for (const m of modes) if (!seen.has(m)) {
|
|
2622
|
+
seen.add(m);
|
|
2623
|
+
out.push(m);
|
|
2624
|
+
}
|
|
2625
|
+
return out;
|
|
2626
|
+
}
|
|
2627
|
+
function stylesEqual(a, b) {
|
|
2628
|
+
if (a === b) return true;
|
|
2629
|
+
if (!a || !b) return false;
|
|
2630
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
2631
|
+
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
2632
|
+
return true;
|
|
2633
|
+
}
|
|
2634
|
+
function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
2635
|
+
if (a.length !== b.length) return false;
|
|
2636
|
+
for (let i = 0; i < a.length; i++) {
|
|
2637
|
+
const pa = a[i];
|
|
2638
|
+
const pb = b[i];
|
|
2639
|
+
if (pa.length !== pb.length) return false;
|
|
2640
|
+
for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
|
|
2641
|
+
}
|
|
2642
|
+
return true;
|
|
2643
|
+
}
|
|
2644
|
+
//#endregion
|
|
2645
|
+
//#region src/tactical-draw/transform-controller.ts
|
|
2646
|
+
/**
|
|
2647
|
+
* Coordinate-space tolerance for `TransformSession.dirty` — mirrors
|
|
2648
|
+
* `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
|
|
2649
|
+
* than a shared import since the two controllers' dirty definitions may
|
|
2650
|
+
* diverge later: a group's dirty is "any member moved").
|
|
2651
|
+
*/
|
|
2652
|
+
const DIRTY_EPSILON = 1e-9;
|
|
2653
|
+
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
2654
|
+
/**
|
|
2655
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
2656
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
2657
|
+
* click, not a gesture — same rationale as `edit-controller.ts`'s constant of
|
|
2658
|
+
* the same name.
|
|
2659
|
+
*/
|
|
2660
|
+
const CLICK_SLOP_PX = 3;
|
|
2661
|
+
/**
|
|
2662
|
+
* Drive an interactive group transform against a `MapAdapter` — the leaner,
|
|
2663
|
+
* multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
|
|
2664
|
+
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
2665
|
+
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
2666
|
+
* grip, drag-anywhere body translate), applied rigidly to every member's
|
|
2667
|
+
* control points at once.
|
|
2668
|
+
*
|
|
2669
|
+
* - lift every member off the graphics layer + per-member working copies
|
|
2670
|
+
* - render every member's preview to the shared preview layer each paint
|
|
2671
|
+
* - group box = fit over the union of every member's rendered pixel
|
|
2672
|
+
* coordinates at the session `boxAngle`; each member also gets its own
|
|
2673
|
+
* thin outline (its own fit at the same angle)
|
|
2674
|
+
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
2675
|
+
* contracts as `runEdit`, scoped to the group
|
|
2676
|
+
* - membership is mutable via `session.setMembers` (#187): members join/leave
|
|
2677
|
+
* a live session without a session boundary
|
|
2678
|
+
*/
|
|
2679
|
+
function runGroupTransform(opts) {
|
|
2680
|
+
const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
2681
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2682
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2683
|
+
/** Build a fresh working record for `measure`, snapshotting its edit-start points. */
|
|
2684
|
+
function createMemberState(measure) {
|
|
2685
|
+
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
2686
|
+
return {
|
|
2687
|
+
measure,
|
|
2688
|
+
editStartPoints,
|
|
2689
|
+
workingPoints: editStartPoints.map((p) => [...p]),
|
|
2690
|
+
optionsOverride: null,
|
|
2691
|
+
box: null,
|
|
2692
|
+
lastPreviewFeatures: []
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
/** The single mutable source of truth for the current member set. */
|
|
2696
|
+
let members = measures.map(createMemberState);
|
|
2697
|
+
/**
|
|
2698
|
+
* `true` once any member has been lifted off the graphics layer (initial or a
|
|
2699
|
+
* mid-session `setMembers` add). Gates the abort-path restore so a session
|
|
2700
|
+
* that never touched the graphics layer doesn't rewrite it on abort.
|
|
2701
|
+
*/
|
|
2702
|
+
let anyLiftedFromGraphics = false;
|
|
2703
|
+
/** A member's own options merged with an override patch (a null override leaves them unchanged). */
|
|
2704
|
+
function mergeOptions(base, override) {
|
|
2705
|
+
if (!override) return base;
|
|
2706
|
+
return {
|
|
2707
|
+
...base ?? {},
|
|
2708
|
+
...override
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
/** Effective options for member `i`: its own options merged with its *live* override. */
|
|
2712
|
+
function effectiveOptions(i) {
|
|
2713
|
+
const member = members[i];
|
|
2714
|
+
return mergeOptions(member.measure.options, member.optionsOverride);
|
|
2715
|
+
}
|
|
2716
|
+
/** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
|
|
2717
|
+
function buildMemberSnapshot(member, override) {
|
|
2718
|
+
const working = {
|
|
2719
|
+
...member.measure,
|
|
2720
|
+
options: mergeOptions(member.measure.options, override),
|
|
2721
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2722
|
+
};
|
|
2723
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
2724
|
+
return {
|
|
2725
|
+
measure: cloneControlMeasure(committed),
|
|
2726
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
/** The working measure for member `i`: original measure, live effective options, live working points. */
|
|
2730
|
+
function workingMeasureFor(i) {
|
|
2731
|
+
const member = members[i];
|
|
2732
|
+
return {
|
|
2733
|
+
...member.measure,
|
|
2734
|
+
options: effectiveOptions(i),
|
|
2735
|
+
controlPoints: member.workingPoints.map((p) => [...p])
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
let dirty = false;
|
|
2739
|
+
/**
|
|
2740
|
+
* Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
|
|
2741
|
+
* even when every control point still matches edit-start — mirrors
|
|
2742
|
+
* `edit-controller.ts`'s flag of the same name.
|
|
2743
|
+
*/
|
|
2744
|
+
let nonGeometryDirty = false;
|
|
2745
|
+
let dragInFlight = false;
|
|
2746
|
+
/** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
|
|
2747
|
+
let suppressClickAway = false;
|
|
2748
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
2749
|
+
const commitListeners = /* @__PURE__ */ new Set();
|
|
2750
|
+
const settleListeners = /* @__PURE__ */ new Set();
|
|
2751
|
+
/** Set exactly once, by `emitSettled` — also the "has settled been emitted" latch and the reason replayed to late registrations. */
|
|
2752
|
+
let settledReason = null;
|
|
2753
|
+
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
2754
|
+
let boxAngle = 0;
|
|
2755
|
+
/** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
|
|
2756
|
+
let groupBox = null;
|
|
2757
|
+
let ctx;
|
|
2758
|
+
let driver;
|
|
2759
|
+
const dragProjection = {
|
|
2760
|
+
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2761
|
+
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2762
|
+
};
|
|
2763
|
+
/**
|
|
2764
|
+
* Lift `member` off the graphics layer when the host reports it currently
|
|
2765
|
+
* there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
|
|
2766
|
+
* Used for both initial and mid-session-added members.
|
|
2767
|
+
*/
|
|
2768
|
+
function liftMemberFromGraphics(member) {
|
|
2769
|
+
if (!isOnGraphicsLayer(member.measure.id)) return;
|
|
2770
|
+
const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
2771
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
2772
|
+
anyLiftedFromGraphics = true;
|
|
2773
|
+
}
|
|
2774
|
+
function pixelsFor(features) {
|
|
2775
|
+
const positions = [];
|
|
2776
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
2777
|
+
const pixels = [];
|
|
2778
|
+
for (const p of positions) {
|
|
2779
|
+
const px = driver.toPixel(p);
|
|
2780
|
+
if (px) pixels.push(px);
|
|
2781
|
+
}
|
|
2782
|
+
return pixels;
|
|
2783
|
+
}
|
|
2784
|
+
/** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
|
|
2785
|
+
function currentAngle() {
|
|
2786
|
+
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
|
|
2790
|
+
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
2791
|
+
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
2792
|
+
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
2793
|
+
* still re-fitted every paint at the drag's live angle, since they are a
|
|
2794
|
+
* derived display only (no self-fight risk).
|
|
2795
|
+
*/
|
|
2796
|
+
function refitBoxes() {
|
|
2797
|
+
const angle = currentAngle();
|
|
2798
|
+
const padding = interactionStyle?.boxPadding ?? 0;
|
|
2799
|
+
const allPixels = [];
|
|
2800
|
+
for (const member of members) {
|
|
2801
|
+
const pixels = pixelsFor(member.lastPreviewFeatures);
|
|
2802
|
+
allPixels.push(...pixels);
|
|
2803
|
+
member.box = fitTransformBox(pixels, angle, padding);
|
|
2804
|
+
}
|
|
2805
|
+
if (activeBoxDrag) return;
|
|
2806
|
+
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
2807
|
+
}
|
|
2808
|
+
function renderAllPreviews() {
|
|
2809
|
+
if (ctx.settled) return;
|
|
2810
|
+
const allFeatures = [];
|
|
2811
|
+
members.forEach((member, i) => {
|
|
2812
|
+
const working = workingMeasureFor(i);
|
|
2813
|
+
let features;
|
|
2814
|
+
try {
|
|
2815
|
+
features = renderMeasureForAdapter(adapter, working, renderOpts).features;
|
|
2816
|
+
} catch {
|
|
2817
|
+
features = [];
|
|
2818
|
+
}
|
|
2819
|
+
member.lastPreviewFeatures = features;
|
|
2820
|
+
allFeatures.push(...features);
|
|
2821
|
+
});
|
|
2822
|
+
ctx.writeLayer(previewLayer, allFeatures);
|
|
2823
|
+
refitBoxes();
|
|
2824
|
+
}
|
|
2825
|
+
function rescaleOnViewChange() {
|
|
2826
|
+
if (ctx.settled) return;
|
|
2827
|
+
if (members.some((m) => measureUsesAdapterResolution({
|
|
2828
|
+
kind: m.measure.kind,
|
|
2829
|
+
options: m.measure.options
|
|
2830
|
+
}))) renderAllPreviews();
|
|
2831
|
+
else refitBoxes();
|
|
2832
|
+
renderHandlesLayer();
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
|
|
2836
|
+
* so the group reads as dashed per the reference design — unless the host
|
|
2837
|
+
* explicitly set their own `strokeDash`, which wins.
|
|
2838
|
+
*/
|
|
2839
|
+
function groupOutlineStyle() {
|
|
2840
|
+
const base = interactionStyle?.boxOutline;
|
|
2841
|
+
if (base?.strokeDash !== void 0) return base;
|
|
2842
|
+
return {
|
|
2843
|
+
...base ?? {},
|
|
2844
|
+
strokeDash: [4, 4]
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
function renderHandlesLayer() {
|
|
2848
|
+
if (ctx.settled) return;
|
|
2849
|
+
const features = [];
|
|
2850
|
+
const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
|
|
2851
|
+
if (liveGroupBox) {
|
|
2852
|
+
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
|
|
2853
|
+
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2854
|
+
}
|
|
2855
|
+
for (const member of members) {
|
|
2856
|
+
const memberBox = member.box;
|
|
2857
|
+
if (!memberBox) continue;
|
|
2858
|
+
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
|
|
2859
|
+
}
|
|
2860
|
+
ctx.writeLayer(handlesLayer, features);
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Builds every member's committed snapshot against a given override array
|
|
2864
|
+
* rather than the live per-member override — used to render a gesture's
|
|
2865
|
+
* pre-freeze "previous" snapshot when a rotate has already mutated the live
|
|
2866
|
+
* overrides by the time the commit runs. Index-aligned with `members`, which
|
|
2867
|
+
* is sound because membership cannot change within a gesture emission.
|
|
2868
|
+
*/
|
|
2869
|
+
function rebuildRenderedAllWith(overrides) {
|
|
2870
|
+
return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
|
|
2871
|
+
}
|
|
2872
|
+
function rebuildRenderedAll() {
|
|
2873
|
+
return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
|
|
2874
|
+
}
|
|
2875
|
+
function recomputeDirty() {
|
|
2876
|
+
dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
|
|
2877
|
+
}
|
|
2878
|
+
/** Split `points` (the concatenation of every member's control points) back per member. */
|
|
2879
|
+
function applyConcatenatedPoints(points) {
|
|
2880
|
+
const rounded = roundControlPoints(points);
|
|
2881
|
+
let offset = 0;
|
|
2882
|
+
for (const member of members) {
|
|
2883
|
+
const c = member.workingPoints.length;
|
|
2884
|
+
const slice = rounded.slice(offset, offset + c);
|
|
2885
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...slice);
|
|
2886
|
+
offset += c;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
function concatWorkingPoints() {
|
|
2890
|
+
const out = [];
|
|
2891
|
+
for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
|
|
2892
|
+
return out;
|
|
2893
|
+
}
|
|
2894
|
+
function clonePointsByMember() {
|
|
2895
|
+
return members.map((m) => m.workingPoints.map((p) => [...p]));
|
|
2896
|
+
}
|
|
2897
|
+
function restorePointsByMember(snapshot) {
|
|
2898
|
+
members.forEach((member, i) => {
|
|
2899
|
+
member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
function cloneOptionsOverrideByMember() {
|
|
2903
|
+
return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
|
|
2904
|
+
}
|
|
2905
|
+
/** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
|
|
2906
|
+
function applyOptionsOverrides(overrides) {
|
|
2907
|
+
members.forEach((member, i) => {
|
|
2908
|
+
const o = overrides[i];
|
|
2909
|
+
member.optionsOverride = o ? { ...o } : null;
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
/**
|
|
2913
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
2914
|
+
* anchor) for every member, the moment a group rotate gesture starts —
|
|
2915
|
+
* mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
|
|
2916
|
+
* per-member since the group has no single working-options set.
|
|
2917
|
+
*/
|
|
2918
|
+
function freezeOrientationForRotate() {
|
|
2919
|
+
members.forEach((member, i) => {
|
|
2920
|
+
const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
|
|
2921
|
+
if (patch) member.optionsOverride = {
|
|
2922
|
+
...member.optionsOverride ?? {},
|
|
2923
|
+
...patch
|
|
2924
|
+
};
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
/**
|
|
2928
|
+
* Emit a completed gesture to every registered listener — the group mirror
|
|
2929
|
+
* of `edit-controller.ts`'s `emitChange` (issue #49 contract): same
|
|
2930
|
+
* instance to all listeners in order, deferred `reject`/`close`/`abort`
|
|
2931
|
+
* (close/abort override reject), exception collection with `AggregateError`.
|
|
2932
|
+
*/
|
|
2933
|
+
function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
|
|
2934
|
+
if (ctx.settled) return;
|
|
2935
|
+
let rejectRequested = false;
|
|
2936
|
+
let closeRequested = false;
|
|
2937
|
+
let abortRequested = false;
|
|
2938
|
+
const event = {
|
|
2939
|
+
measures: currentRendered,
|
|
2940
|
+
previous: previousRendered,
|
|
2941
|
+
session,
|
|
2942
|
+
reject() {
|
|
2943
|
+
rejectRequested = true;
|
|
2944
|
+
},
|
|
2945
|
+
close() {
|
|
2946
|
+
closeRequested = true;
|
|
2947
|
+
},
|
|
2948
|
+
abort() {
|
|
2949
|
+
abortRequested = true;
|
|
2950
|
+
}
|
|
2951
|
+
};
|
|
2952
|
+
const membersAtEmission = members;
|
|
2953
|
+
const errors = [];
|
|
2954
|
+
for (const listener of [...changeListeners]) try {
|
|
2955
|
+
listener(event);
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
errors.push(err);
|
|
2958
|
+
}
|
|
2959
|
+
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
2960
|
+
else if (closeRequested) commitClose();
|
|
2961
|
+
else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
|
|
2962
|
+
restorePointsByMember(previousPointsByMember);
|
|
2963
|
+
sizeAnchor = previousSizeAnchor;
|
|
2964
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
2965
|
+
applyOptionsOverrides(previousOptionsOverrideByMember);
|
|
2966
|
+
renderAllPreviews();
|
|
2967
|
+
renderHandlesLayer();
|
|
2968
|
+
recomputeDirty();
|
|
2969
|
+
}
|
|
2970
|
+
if (errors.length === 1) throw errors[0];
|
|
2971
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Deliver a committed exit-snapshot batch to every `onCommit` listener — the
|
|
2975
|
+
* single delivery channel for both the `setMembers`-removed batch and the
|
|
2976
|
+
* close-time batch (#191). Same exception-collection contract as `emitChange`.
|
|
2977
|
+
*/
|
|
2978
|
+
function emitCommit(snapshots) {
|
|
2979
|
+
const errors = [];
|
|
2980
|
+
for (const listener of [...commitListeners]) try {
|
|
2981
|
+
listener(snapshots);
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
errors.push(err);
|
|
2984
|
+
}
|
|
2985
|
+
if (errors.length === 1) throw errors[0];
|
|
2986
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onCommit listener errors");
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
|
|
2990
|
+
* replays its own `reason` verbatim; anything else (a `produce` throw from
|
|
2991
|
+
* `ctx.commit`) is the close-time snapshot build failing, reported as
|
|
2992
|
+
* `"error"`.
|
|
2993
|
+
*/
|
|
2994
|
+
function settleReasonFromError(error) {
|
|
2995
|
+
return isTacticalDrawAbortError(error) ? error.reason : "error";
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Deliver the session's settlement to every `onSettled` listener — fires
|
|
2999
|
+
* exactly once, whichever path settles first wins the reason. Mirrors
|
|
3000
|
+
* `emitCommit`'s exception-collection contract (one throw if single,
|
|
3001
|
+
* `AggregateError` otherwise), so a throwing listener cannot suppress a
|
|
3002
|
+
* later one nor leave `settledReason` unset.
|
|
3003
|
+
*/
|
|
3004
|
+
function emitSettled(reason) {
|
|
3005
|
+
if (settledReason !== null) return;
|
|
3006
|
+
settledReason = reason;
|
|
3007
|
+
const listeners = [...settleListeners];
|
|
3008
|
+
settleListeners.clear();
|
|
3009
|
+
const errors = [];
|
|
3010
|
+
for (const listener of listeners) try {
|
|
3011
|
+
listener(reason);
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
errors.push(err);
|
|
3014
|
+
}
|
|
3015
|
+
if (errors.length === 1) throw errors[0];
|
|
3016
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onSettled listener errors");
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* Commit via `ctx.commit` and emit the close batch to `onCommit` listeners,
|
|
3020
|
+
* then emit the settle to `onSettled` listeners (#194) — in a try/finally so
|
|
3021
|
+
* a throwing `onCommit` listener cannot starve the settle emission. The
|
|
3022
|
+
* emission happens after the interaction core has settled (teardown and
|
|
3023
|
+
* `onSettled` already ran inside `ctx.commit`), so a listener that calls
|
|
3024
|
+
* `td.render()` sees these members no longer excluded from reconcile — the
|
|
3025
|
+
* fold-then-render pattern works from inside the handler. `undefined` covers
|
|
3026
|
+
* both an already-settled session and a `produce` throw (the promise
|
|
3027
|
+
* rejects); neither emits here — a `produce` throw already emitted the
|
|
3028
|
+
* settle with reason `"error"` via the wrapped `onSettled` passed to
|
|
3029
|
+
* `runInteraction`.
|
|
3030
|
+
*/
|
|
3031
|
+
function commitClose() {
|
|
3032
|
+
const snapshots = ctx.commit(rebuildRenderedAll);
|
|
3033
|
+
if (snapshots !== void 0) try {
|
|
3034
|
+
emitCommit(snapshots);
|
|
3035
|
+
} finally {
|
|
3036
|
+
emitSettled("close");
|
|
3037
|
+
}
|
|
3038
|
+
return snapshots;
|
|
3039
|
+
}
|
|
3040
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
|
|
3041
|
+
if (ctx.settled) return;
|
|
3042
|
+
const previousPointsByMember = clonePointsByMember();
|
|
3043
|
+
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
3044
|
+
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
3045
|
+
applyConcatenatedPoints(nextConcatPoints);
|
|
3046
|
+
let currentRendered;
|
|
3047
|
+
try {
|
|
3048
|
+
currentRendered = rebuildRenderedAll();
|
|
3049
|
+
} catch (err) {
|
|
3050
|
+
restorePointsByMember(previousPointsByMember);
|
|
3051
|
+
dragInFlight = false;
|
|
3052
|
+
throw err;
|
|
3053
|
+
}
|
|
3054
|
+
renderAllPreviews();
|
|
3055
|
+
renderHandlesLayer();
|
|
3056
|
+
recomputeDirty();
|
|
3057
|
+
dragInFlight = false;
|
|
3058
|
+
emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3059
|
+
}
|
|
3060
|
+
function setSizeAnchor(next) {
|
|
3061
|
+
if (ctx.settled || sizeAnchor === next) return;
|
|
3062
|
+
const previousPointsByMember = clonePointsByMember();
|
|
3063
|
+
const previousSizeAnchor = sizeAnchor;
|
|
3064
|
+
const previousNonGeometryDirty = nonGeometryDirty;
|
|
3065
|
+
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3066
|
+
const previousRendered = rebuildRenderedAll();
|
|
3067
|
+
sizeAnchor = next;
|
|
3068
|
+
const currentRendered = rebuildRenderedAll();
|
|
3069
|
+
nonGeometryDirty = true;
|
|
3070
|
+
dirty = true;
|
|
3071
|
+
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Cancel an in-flight box drag without committing it: restore the pre-drag
|
|
3075
|
+
* geometry and option overrides, re-enable drag pan, and clear the drag. Used
|
|
3076
|
+
* by `setMembers` so a membership change never bakes a half-finished drag; the
|
|
3077
|
+
* caller is responsible for the follow-up repaint (`renderAllPreviews` /
|
|
3078
|
+
* `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
|
|
3079
|
+
* rather than rendering the outgoing member set only to redo it immediately.
|
|
3080
|
+
*/
|
|
3081
|
+
function cancelActiveBoxDrag() {
|
|
3082
|
+
if (!activeBoxDrag) return;
|
|
3083
|
+
const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3084
|
+
activeBoxDrag = null;
|
|
3085
|
+
driver.setDragPanEnabled(true);
|
|
3086
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3087
|
+
applyOptionsOverrides(preDragOptionsOverrideByMember);
|
|
3088
|
+
dragInFlight = false;
|
|
3089
|
+
}
|
|
3090
|
+
function setMembers(next) {
|
|
3091
|
+
if (ctx.settled) return void 0;
|
|
3092
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3093
|
+
for (const m of next) {
|
|
3094
|
+
if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
|
|
3095
|
+
seen.add(m.id);
|
|
3096
|
+
}
|
|
3097
|
+
if (next.length === 0) return close();
|
|
3098
|
+
cancelActiveBoxDrag();
|
|
3099
|
+
const nextIds = new Set(next.map((m) => m.id));
|
|
3100
|
+
const currentById = new Map(members.map((m) => [m.measure.id, m]));
|
|
3101
|
+
const removedSnapshots = [];
|
|
3102
|
+
for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
|
|
3103
|
+
const nextMembers = [];
|
|
3104
|
+
for (const measure of next) {
|
|
3105
|
+
const existing = currentById.get(measure.id);
|
|
3106
|
+
if (existing) nextMembers.push(existing);
|
|
3107
|
+
else {
|
|
3108
|
+
const member = createMemberState(measure);
|
|
3109
|
+
liftMemberFromGraphics(member);
|
|
3110
|
+
nextMembers.push(member);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
members = nextMembers;
|
|
3114
|
+
renderAllPreviews();
|
|
3115
|
+
renderHandlesLayer();
|
|
3116
|
+
recomputeDirty();
|
|
3117
|
+
if (removedSnapshots.length > 0) emitCommit(removedSnapshots);
|
|
3118
|
+
return removedSnapshots;
|
|
3119
|
+
}
|
|
3120
|
+
function close() {
|
|
3121
|
+
return commitClose();
|
|
3122
|
+
}
|
|
3123
|
+
const session = {
|
|
3124
|
+
get measures() {
|
|
3125
|
+
return members.map((m) => m.measure);
|
|
3126
|
+
},
|
|
3127
|
+
controlPointsFor(measureId) {
|
|
3128
|
+
const member = members.find((m) => m.measure.id === measureId);
|
|
3129
|
+
if (!member) return void 0;
|
|
3130
|
+
return member.workingPoints.map((p) => [...p]);
|
|
1962
3131
|
},
|
|
1963
3132
|
get dirty() {
|
|
1964
3133
|
return dirty;
|
|
1965
3134
|
},
|
|
1966
|
-
get modes() {
|
|
1967
|
-
return modes;
|
|
1968
|
-
},
|
|
1969
3135
|
get sizeAnchor() {
|
|
1970
3136
|
return sizeAnchor;
|
|
1971
3137
|
},
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
ctx.commit(rebuildRendered);
|
|
3138
|
+
setSizeAnchor(next) {
|
|
3139
|
+
setSizeAnchor(next);
|
|
1975
3140
|
},
|
|
3141
|
+
setMembers,
|
|
3142
|
+
close,
|
|
1976
3143
|
abort() {
|
|
1977
3144
|
if (ctx.settled) return;
|
|
1978
3145
|
ctx.fail(new TacticalDrawAbortError("session"));
|
|
1979
3146
|
},
|
|
1980
|
-
setModes(next) {
|
|
1981
|
-
if (ctx.settled) return;
|
|
1982
|
-
const normalised = normaliseModes(next);
|
|
1983
|
-
if (dragInFlight) {
|
|
1984
|
-
pendingModes = normalised;
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
applyModes(normalised);
|
|
1988
|
-
},
|
|
1989
|
-
setOptions(partial) {
|
|
1990
|
-
setOptions(partial);
|
|
1991
|
-
},
|
|
1992
|
-
setStyle(partial) {
|
|
1993
|
-
setStyle(partial);
|
|
1994
|
-
},
|
|
1995
|
-
setSizeAnchor(next) {
|
|
1996
|
-
setSizeAnchor(next);
|
|
1997
|
-
},
|
|
1998
3147
|
onChange(listener) {
|
|
1999
3148
|
if (ctx.settled) return () => {};
|
|
2000
3149
|
changeListeners.add(listener);
|
|
@@ -2002,139 +3151,103 @@ function runEdit(opts) {
|
|
|
2002
3151
|
changeListeners.delete(listener);
|
|
2003
3152
|
};
|
|
2004
3153
|
},
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
[COMMIT_ROTATE_FOR_TEST]: (angle) => {
|
|
2012
|
-
commitRotate(angle);
|
|
2013
|
-
},
|
|
2014
|
-
[BEGIN_DRAG_FOR_TEST]: () => {
|
|
2015
|
-
if (ctx.settled) return;
|
|
2016
|
-
dragInFlight = true;
|
|
3154
|
+
onCommit(listener) {
|
|
3155
|
+
if (ctx.settled) return () => {};
|
|
3156
|
+
commitListeners.add(listener);
|
|
3157
|
+
return () => {
|
|
3158
|
+
commitListeners.delete(listener);
|
|
3159
|
+
};
|
|
2017
3160
|
},
|
|
2018
|
-
|
|
2019
|
-
if (
|
|
2020
|
-
|
|
2021
|
-
|
|
3161
|
+
onSettled(handler) {
|
|
3162
|
+
if (settledReason !== null) {
|
|
3163
|
+
const reason = settledReason;
|
|
3164
|
+
let cancelled = false;
|
|
3165
|
+
queueMicrotask(() => {
|
|
3166
|
+
if (!cancelled) handler(reason);
|
|
3167
|
+
});
|
|
3168
|
+
return () => {
|
|
3169
|
+
cancelled = true;
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
settleListeners.add(handler);
|
|
3173
|
+
return () => {
|
|
3174
|
+
settleListeners.delete(handler);
|
|
3175
|
+
};
|
|
2022
3176
|
}
|
|
2023
3177
|
};
|
|
2024
|
-
let
|
|
2025
|
-
/**
|
|
2026
|
-
* Render-only rotate-grip override produced by the drag FSM: the start grip
|
|
2027
|
-
* rotated rigidly with the gesture so it tracks the pointer instead of hopping
|
|
2028
|
-
* between near-equidistant vertices. Cleared when the drag ends. Only
|
|
2029
|
-
* consulted while a rotate drag is in flight — pick is gated off for the
|
|
2030
|
-
* duration, so the resting handle set is never read against it (ADR-0016).
|
|
2031
|
-
*/
|
|
2032
|
-
let rotateHandleOverride = null;
|
|
2033
|
-
/**
|
|
2034
|
-
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2035
|
-
* exclusive `delete` mode). Deletion is
|
|
2036
|
-
* confined to variable-length measures (the same condition that enables
|
|
2037
|
-
* midpoint inserts — see ADR-0005) and is denied when it would:
|
|
2038
|
-
* - target a trailing fixed slot (e.g. Axis1's width handle), or
|
|
2039
|
-
* - drop the user-spine point count below the rule's `minimumUserPoints`.
|
|
2040
|
-
* Fixed-count measures re-pad on derive, so deleting a vertex there is moot.
|
|
2041
|
-
*/
|
|
2042
|
-
function canDeleteVertex(index) {
|
|
2043
|
-
if (!midpointHandlesEnabled) return false;
|
|
2044
|
-
if (!modes.includes("reshape") && !modes.includes("delete")) return false;
|
|
2045
|
-
const spineCount = workingPoints.length - trailingFixedSlots;
|
|
2046
|
-
if (index < 0 || index >= spineCount) return false;
|
|
2047
|
-
const minimum = rule?.minimumUserPoints ?? (isAreaKind ? 3 : 2);
|
|
2048
|
-
return spineCount - 1 >= minimum;
|
|
2049
|
-
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Remove the vertex at `index` and commit through the normal gesture path so
|
|
2052
|
-
* a single `EditChangeEvent` covers the delete and dirty tracking stays
|
|
2053
|
-
* consistent. The rule's `transform` runs with the removed index as the
|
|
2054
|
-
* active point so frame-relative invariants survive the delete: for Axis1
|
|
2055
|
-
* this re-projects the trailing width handle into the new tip→neck frame,
|
|
2056
|
-
* keeping the main-attack arrow's width unchanged even when the tip or neck
|
|
2057
|
-
* is removed. Rules without that concern (Line1) pass the points through.
|
|
2058
|
-
*/
|
|
2059
|
-
function deleteVertex(index) {
|
|
2060
|
-
if (ctx.settled) return;
|
|
2061
|
-
const previous = workingPoints.map((p) => [...p]);
|
|
2062
|
-
const spliced = previous.filter((_, i) => i !== index);
|
|
2063
|
-
commitGesture(rule ? rule.transform({
|
|
2064
|
-
previous,
|
|
2065
|
-
next: spliced,
|
|
2066
|
-
activePointIndex: index
|
|
2067
|
-
}) : spliced);
|
|
2068
|
-
}
|
|
3178
|
+
let activeBoxDrag = null;
|
|
2069
3179
|
const onDriverDown = (pixel, info) => {
|
|
2070
3180
|
if (ctx.settled || dragInFlight) return;
|
|
3181
|
+
if (!groupBox) return;
|
|
2071
3182
|
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
deleteVertex(hit.index);
|
|
2080
|
-
suppressClickAway = true;
|
|
2081
|
-
}
|
|
2082
|
-
return;
|
|
2083
|
-
}
|
|
2084
|
-
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
2085
|
-
deleteVertex(hit.index);
|
|
2086
|
-
suppressClickAway = true;
|
|
2087
|
-
return;
|
|
3183
|
+
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
3184
|
+
if (!boxHit) return;
|
|
3185
|
+
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3186
|
+
if (boxHit.kind === "rotate") {
|
|
3187
|
+
freezeOrientationForRotate();
|
|
3188
|
+
renderAllPreviews();
|
|
3189
|
+
renderHandlesLayer();
|
|
2088
3190
|
}
|
|
2089
|
-
const preDragPoints =
|
|
2090
|
-
|
|
2091
|
-
|
|
3191
|
+
const preDragPoints = concatWorkingPoints();
|
|
3192
|
+
activeBoxDrag = {
|
|
3193
|
+
fsm: beginBoxDrag({
|
|
3194
|
+
kind: boxHit.kind,
|
|
3195
|
+
downPixel: pixel,
|
|
3196
|
+
points: preDragPoints,
|
|
3197
|
+
projection: dragProjection,
|
|
3198
|
+
box: groupBox,
|
|
3199
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
3200
|
+
}),
|
|
3201
|
+
preDragPoints,
|
|
3202
|
+
preDragBox: groupBox,
|
|
2092
3203
|
downPixel: pixel,
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
fsm,
|
|
2099
|
-
preDragPoints
|
|
3204
|
+
lastSnapshot: {
|
|
3205
|
+
points: preDragPoints,
|
|
3206
|
+
box: groupBox
|
|
3207
|
+
},
|
|
3208
|
+
preDragOptionsOverrideByMember
|
|
2100
3209
|
};
|
|
2101
|
-
if (fsm.kind === "midpoint") {
|
|
2102
|
-
workingPoints.splice(0, workingPoints.length, ...initialPoints);
|
|
2103
|
-
renderPreview();
|
|
2104
|
-
renderHandlesLayer();
|
|
2105
|
-
}
|
|
2106
3210
|
dragInFlight = true;
|
|
2107
3211
|
driver.setDragPanEnabled(false);
|
|
2108
3212
|
};
|
|
2109
|
-
function
|
|
2110
|
-
if (!
|
|
2111
|
-
const snapshot =
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
3213
|
+
function paintBoxDragSnapshot(pixel) {
|
|
3214
|
+
if (!activeBoxDrag) return;
|
|
3215
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
3216
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
3217
|
+
applyConcatenatedPoints(snapshot.points);
|
|
3218
|
+
renderAllPreviews();
|
|
2115
3219
|
renderHandlesLayer();
|
|
2116
3220
|
recomputeDirty();
|
|
2117
3221
|
}
|
|
2118
3222
|
const onDriverMove = (pixel) => {
|
|
2119
|
-
if (ctx.settled
|
|
2120
|
-
|
|
3223
|
+
if (ctx.settled) return;
|
|
3224
|
+
if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
2121
3225
|
};
|
|
2122
3226
|
const onDriverUp = (pixel) => {
|
|
2123
|
-
if (!
|
|
3227
|
+
if (!activeBoxDrag) return;
|
|
2124
3228
|
if (ctx.settled) {
|
|
2125
|
-
|
|
2126
|
-
rotateHandleOverride = null;
|
|
3229
|
+
activeBoxDrag = null;
|
|
2127
3230
|
dragInFlight = false;
|
|
2128
3231
|
driver.setDragPanEnabled(true);
|
|
2129
3232
|
return;
|
|
2130
3233
|
}
|
|
2131
|
-
const { fsm, preDragPoints } =
|
|
2132
|
-
|
|
2133
|
-
rotateHandleOverride = null;
|
|
3234
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3235
|
+
activeBoxDrag = null;
|
|
2134
3236
|
driver.setDragPanEnabled(true);
|
|
2135
|
-
const
|
|
2136
|
-
|
|
2137
|
-
|
|
3237
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
3238
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
|
|
3239
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3240
|
+
renderAllPreviews();
|
|
3241
|
+
renderHandlesLayer();
|
|
3242
|
+
recomputeDirty();
|
|
3243
|
+
dragInFlight = false;
|
|
3244
|
+
suppressClickAway = true;
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
const finalSnapshot = fsm.move(pixel);
|
|
3248
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3249
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
3250
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
|
|
2138
3251
|
};
|
|
2139
3252
|
let pickPreview;
|
|
2140
3253
|
let pickHandles;
|
|
@@ -2162,27 +3275,27 @@ function runEdit(opts) {
|
|
|
2162
3275
|
return;
|
|
2163
3276
|
}
|
|
2164
3277
|
if (localPreview || localHandles) return;
|
|
2165
|
-
|
|
2166
|
-
|
|
3278
|
+
const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
|
|
3279
|
+
if (localGraphics && !isMember) {
|
|
3280
|
+
commitClose();
|
|
2167
3281
|
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
2168
3282
|
return;
|
|
2169
3283
|
}
|
|
2170
|
-
|
|
3284
|
+
commitClose();
|
|
2171
3285
|
};
|
|
2172
3286
|
const core = runInteraction({
|
|
2173
3287
|
adapter,
|
|
2174
3288
|
signal,
|
|
2175
|
-
layers: [
|
|
2176
|
-
previewLayer,
|
|
2177
|
-
guideLayer,
|
|
2178
|
-
handlesLayer
|
|
2179
|
-
],
|
|
3289
|
+
layers: [previewLayer, handlesLayer],
|
|
2180
3290
|
onViewChange: rescaleOnViewChange,
|
|
2181
|
-
onSettled
|
|
3291
|
+
onSettled: (settlement) => {
|
|
3292
|
+
if (onSettled) onSettled();
|
|
3293
|
+
if (settlement.status === "rejected") emitSettled(settleReasonFromError(settlement.error));
|
|
3294
|
+
},
|
|
2182
3295
|
attach(c) {
|
|
2183
3296
|
ctx = c;
|
|
2184
3297
|
driver = adapter.createEditPointerDriver();
|
|
2185
|
-
|
|
3298
|
+
for (const member of members) liftMemberFromGraphics(member);
|
|
2186
3299
|
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
2187
3300
|
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
2188
3301
|
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
@@ -2198,15 +3311,16 @@ function runEdit(opts) {
|
|
|
2198
3311
|
unsubDown();
|
|
2199
3312
|
unsubMove();
|
|
2200
3313
|
unsubUp();
|
|
2201
|
-
if (
|
|
3314
|
+
if (activeBoxDrag !== null) {
|
|
2202
3315
|
driver.setDragPanEnabled(true);
|
|
2203
|
-
|
|
2204
|
-
rotateHandleOverride = null;
|
|
3316
|
+
activeBoxDrag = null;
|
|
2205
3317
|
dragInFlight = false;
|
|
2206
3318
|
}
|
|
2207
3319
|
driver.dispose();
|
|
2208
|
-
},
|
|
2209
|
-
|
|
3320
|
+
}, () => {
|
|
3321
|
+
if (anyLiftedFromGraphics) restoreOriginalToGraphics();
|
|
3322
|
+
});
|
|
3323
|
+
renderAllPreviews();
|
|
2210
3324
|
renderHandlesLayer();
|
|
2211
3325
|
recomputeDirty();
|
|
2212
3326
|
if (onSession) onSession(session);
|
|
@@ -2217,50 +3331,14 @@ function runEdit(opts) {
|
|
|
2217
3331
|
get settled() {
|
|
2218
3332
|
return core.settled;
|
|
2219
3333
|
},
|
|
2220
|
-
|
|
3334
|
+
get measureIds() {
|
|
3335
|
+
return members.map((m) => m.measure.id);
|
|
3336
|
+
},
|
|
2221
3337
|
abort(reason, cause) {
|
|
2222
3338
|
core.abort(reason, cause);
|
|
2223
|
-
},
|
|
2224
|
-
notifyExternalMeasure(next) {
|
|
2225
|
-
if (core.settled) return;
|
|
2226
|
-
if (stylesEqual(currentStyle, next.style)) return;
|
|
2227
|
-
currentStyle = next.style;
|
|
2228
|
-
renderPreview();
|
|
2229
3339
|
}
|
|
2230
3340
|
};
|
|
2231
3341
|
}
|
|
2232
|
-
function normaliseModes(modes) {
|
|
2233
|
-
if (!modes || modes.length === 0) return ["reshape"];
|
|
2234
|
-
for (const m of modes) {
|
|
2235
|
-
if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2236
|
-
if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2237
|
-
}
|
|
2238
|
-
if (modes.includes("delete")) return ["delete"];
|
|
2239
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2240
|
-
const out = [];
|
|
2241
|
-
for (const m of modes) if (!seen.has(m)) {
|
|
2242
|
-
seen.add(m);
|
|
2243
|
-
out.push(m);
|
|
2244
|
-
}
|
|
2245
|
-
return out;
|
|
2246
|
-
}
|
|
2247
|
-
function stylesEqual(a, b) {
|
|
2248
|
-
if (a === b) return true;
|
|
2249
|
-
if (!a || !b) return false;
|
|
2250
|
-
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
2251
|
-
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
2252
|
-
return true;
|
|
2253
|
-
}
|
|
2254
|
-
function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
2255
|
-
if (a.length !== b.length) return false;
|
|
2256
|
-
for (let i = 0; i < a.length; i++) {
|
|
2257
|
-
const pa = a[i];
|
|
2258
|
-
const pb = b[i];
|
|
2259
|
-
if (pa.length !== pb.length) return false;
|
|
2260
|
-
for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
|
|
2261
|
-
}
|
|
2262
|
-
return true;
|
|
2263
|
-
}
|
|
2264
3342
|
//#endregion
|
|
2265
3343
|
//#region src/tactical-draw/errors.ts
|
|
2266
3344
|
/**
|
|
@@ -2304,6 +3382,20 @@ var TacticalDraw = class {
|
|
|
2304
3382
|
*/
|
|
2305
3383
|
activeEdit = null;
|
|
2306
3384
|
/**
|
|
3385
|
+
* Tracked alongside `activeInteraction` whenever the interaction is a group
|
|
3386
|
+
* transform (`editMany`). The `render()` / view-change paths need every
|
|
3387
|
+
* member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
|
|
3388
|
+
* but for `TransformHandle`'s `measureIds` array.
|
|
3389
|
+
*/
|
|
3390
|
+
activeTransform = null;
|
|
3391
|
+
/**
|
|
3392
|
+
* The live `TransformSession`, exposed via `activeTransformSession`. Set
|
|
3393
|
+
* directly by `editMany`'s `onSession` wrapper (not routed through
|
|
3394
|
+
* `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
|
|
3395
|
+
* and cleared on settle.
|
|
3396
|
+
*/
|
|
3397
|
+
activeTransformSessionRef = null;
|
|
3398
|
+
/**
|
|
2307
3399
|
* Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
|
|
2308
3400
|
* façade subscribes once to `adapter.onPick` per layer and routes events
|
|
2309
3401
|
* through this map so that close-then-pick ordering during an active edit can
|
|
@@ -2321,6 +3413,7 @@ var TacticalDraw = class {
|
|
|
2321
3413
|
deferredGraphicsPick = null;
|
|
2322
3414
|
idCounter = 0;
|
|
2323
3415
|
editCounter = 0;
|
|
3416
|
+
transformCounter = 0;
|
|
2324
3417
|
/**
|
|
2325
3418
|
* Snapshot of the host's last `render()` argument. The edit slice uses it
|
|
2326
3419
|
* to recompute the graphics-layer state during a lift / restore — the
|
|
@@ -2372,12 +3465,35 @@ var TacticalDraw = class {
|
|
|
2372
3465
|
*/
|
|
2373
3466
|
handleViewChange = () => {
|
|
2374
3467
|
if (this.destroyed || !this.hasRendered) return;
|
|
2375
|
-
const
|
|
2376
|
-
const visible =
|
|
3468
|
+
const excluded = this.excludedInteractionIds();
|
|
3469
|
+
const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
|
|
2377
3470
|
if (!visible.some(measureUsesAdapterResolution)) return;
|
|
2378
3471
|
this.renderMeasuresToGraphics(visible);
|
|
2379
3472
|
};
|
|
2380
3473
|
/**
|
|
3474
|
+
* Every measure id currently lifted onto the preview layer by an active
|
|
3475
|
+
* edit or group transform — excluded from `render()` / view-change
|
|
3476
|
+
* reconciliation because the preview layer (not graphics) owns their
|
|
3477
|
+
* working copy. Empty while idle.
|
|
3478
|
+
*/
|
|
3479
|
+
excludedInteractionIds() {
|
|
3480
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3481
|
+
if (this.activeEdit) ids.add(this.activeEdit.measureId);
|
|
3482
|
+
for (const id of this.transformMemberIds() ?? []) ids.add(id);
|
|
3483
|
+
return ids;
|
|
3484
|
+
}
|
|
3485
|
+
/**
|
|
3486
|
+
* Live member ids of the active group transform, or `undefined` when none.
|
|
3487
|
+
* Falls back to the session ref because `onSession` fires from inside the
|
|
3488
|
+
* controller's attach, before `runWithSlot` has assigned `activeTransform` —
|
|
3489
|
+
* the session ref is registered first (`trackTransformSession`), so a
|
|
3490
|
+
* `render()` from inside `onSession` still sees live membership.
|
|
3491
|
+
*/
|
|
3492
|
+
transformMemberIds() {
|
|
3493
|
+
if (this.activeTransform) return this.activeTransform.measureIds;
|
|
3494
|
+
return this.activeTransformSessionRef?.measures.map((m) => m.id);
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
2381
3497
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
2382
3498
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
2383
3499
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -2416,7 +3532,10 @@ var TacticalDraw = class {
|
|
|
2416
3532
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
2417
3533
|
editedRemoved = externalEdited === void 0;
|
|
2418
3534
|
}
|
|
2419
|
-
const
|
|
3535
|
+
const transformIds = this.transformMemberIds();
|
|
3536
|
+
const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
|
|
3537
|
+
const excluded = this.excludedInteractionIds();
|
|
3538
|
+
const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
|
|
2420
3539
|
if (!(visible.length === 0 && !this.hasRendered)) {
|
|
2421
3540
|
this.renderMeasuresToGraphics(visible);
|
|
2422
3541
|
this.hasRendered = true;
|
|
@@ -2429,6 +3548,16 @@ var TacticalDraw = class {
|
|
|
2429
3548
|
editHandle.abort("removed");
|
|
2430
3549
|
});
|
|
2431
3550
|
}
|
|
3551
|
+
if (transformMemberRemoved) {
|
|
3552
|
+
const sessionRef = this.activeTransformSessionRef;
|
|
3553
|
+
queueMicrotask(() => {
|
|
3554
|
+
const handle = this.activeTransform;
|
|
3555
|
+
if (!handle || handle.settled) return;
|
|
3556
|
+
if (this.activeTransformSessionRef !== sessionRef) return;
|
|
3557
|
+
const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
|
|
3558
|
+
if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
2432
3561
|
}
|
|
2433
3562
|
/**
|
|
2434
3563
|
* Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
|
|
@@ -2446,6 +3575,8 @@ var TacticalDraw = class {
|
|
|
2446
3575
|
}
|
|
2447
3576
|
if (this.activeInteraction.isActive) {
|
|
2448
3577
|
this.activeEdit = null;
|
|
3578
|
+
this.activeTransform = null;
|
|
3579
|
+
this.activeTransformSessionRef = null;
|
|
2449
3580
|
this.activeInteraction.cancel("preempted");
|
|
2450
3581
|
}
|
|
2451
3582
|
return null;
|
|
@@ -2461,14 +3592,17 @@ var TacticalDraw = class {
|
|
|
2461
3592
|
* whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
|
|
2462
3593
|
* caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
|
|
2463
3594
|
*/
|
|
2464
|
-
runWithSlot(create,
|
|
3595
|
+
runWithSlot(create, activate, extraSettle) {
|
|
2465
3596
|
let handle;
|
|
2466
|
-
let
|
|
3597
|
+
let deactivate = null;
|
|
2467
3598
|
let settledDuringCreate = false;
|
|
2468
3599
|
const clearSlot = () => {
|
|
2469
3600
|
if (handle) {
|
|
2470
3601
|
this.activeInteraction.clear(handle);
|
|
2471
|
-
if (
|
|
3602
|
+
if (deactivate) {
|
|
3603
|
+
deactivate();
|
|
3604
|
+
deactivate = null;
|
|
3605
|
+
}
|
|
2472
3606
|
} else {
|
|
2473
3607
|
settledDuringCreate = true;
|
|
2474
3608
|
this.activeInteraction.clear();
|
|
@@ -2481,7 +3615,6 @@ var TacticalDraw = class {
|
|
|
2481
3615
|
this.activeInteraction.begin();
|
|
2482
3616
|
try {
|
|
2483
3617
|
handle = create(onSettled);
|
|
2484
|
-
editHandle = editHandleOf ? editHandleOf(handle) : null;
|
|
2485
3618
|
} catch (error) {
|
|
2486
3619
|
this.activeInteraction.clear();
|
|
2487
3620
|
if (!settledDuringCreate && extraSettle) extraSettle();
|
|
@@ -2489,7 +3622,7 @@ var TacticalDraw = class {
|
|
|
2489
3622
|
}
|
|
2490
3623
|
if (!settledDuringCreate) {
|
|
2491
3624
|
this.activeInteraction.set(handle);
|
|
2492
|
-
if (
|
|
3625
|
+
if (activate && !handle.settled) deactivate = activate(handle) ?? null;
|
|
2493
3626
|
}
|
|
2494
3627
|
handle.promise.then(clearSlot, clearSlot);
|
|
2495
3628
|
return handle.promise;
|
|
@@ -2505,6 +3638,18 @@ var TacticalDraw = class {
|
|
|
2505
3638
|
};
|
|
2506
3639
|
}
|
|
2507
3640
|
/**
|
|
3641
|
+
* Wrap a host `onSession` callback for `editMany`, recording the session on
|
|
3642
|
+
* `activeTransformSessionRef` (exposed via `activeTransformSession`) before
|
|
3643
|
+
* the host's own handler runs. Separate from `trackSession` because
|
|
3644
|
+
* `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
|
|
3645
|
+
*/
|
|
3646
|
+
trackTransformSession(hostOnSession) {
|
|
3647
|
+
return (session) => {
|
|
3648
|
+
this.activeTransformSessionRef = session;
|
|
3649
|
+
hostOnSession?.(session);
|
|
3650
|
+
};
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
2508
3653
|
* Start an interactive draw. Fixed-length kinds commit automatically on the
|
|
2509
3654
|
* last required click. Variable-length kinds surface a `DrawSession` via
|
|
2510
3655
|
* `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
|
|
@@ -2531,6 +3676,7 @@ var TacticalDraw = class {
|
|
|
2531
3676
|
adapter: this.adapter,
|
|
2532
3677
|
kind,
|
|
2533
3678
|
previewLayer: this.previewLayer,
|
|
3679
|
+
guideLayer: this.guideLayer,
|
|
2534
3680
|
graphicsStyle: this.graphicsStyle,
|
|
2535
3681
|
generateId: () => draft.id ?? this.generateId(),
|
|
2536
3682
|
signal: options?.signal,
|
|
@@ -2570,9 +3716,7 @@ var TacticalDraw = class {
|
|
|
2570
3716
|
*/
|
|
2571
3717
|
edit(measure, options) {
|
|
2572
3718
|
this.assertAlive();
|
|
2573
|
-
|
|
2574
|
-
for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2575
|
-
}
|
|
3719
|
+
normaliseModes(options?.modes);
|
|
2576
3720
|
const preempt = this.guardAndPreempt();
|
|
2577
3721
|
if (preempt) return preempt;
|
|
2578
3722
|
const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
|
|
@@ -2605,7 +3749,121 @@ var TacticalDraw = class {
|
|
|
2605
3749
|
wasOnGraphicsLayer,
|
|
2606
3750
|
onSettled,
|
|
2607
3751
|
onClickAwayGraphicsPick
|
|
2608
|
-
}), (handle) =>
|
|
3752
|
+
}), (handle) => {
|
|
3753
|
+
this.activeEdit = handle;
|
|
3754
|
+
return () => {
|
|
3755
|
+
if (this.activeEdit === handle) this.activeEdit = null;
|
|
3756
|
+
};
|
|
3757
|
+
}, revertGuideStyle);
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Start an interactive group transform (multi-select) over `measures`
|
|
3761
|
+
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
3762
|
+
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
3763
|
+
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
3764
|
+
* resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
|
|
3765
|
+
* with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
|
|
3766
|
+
* `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
|
|
3767
|
+
* the member set is mutable (`session.setMembers`, #187) so snapshot arrays
|
|
3768
|
+
* are id-keyed, not index-aligned with this input. The promise resolves with
|
|
3769
|
+
* the members in the set at close time; a member removed mid-session had its
|
|
3770
|
+
* exit snapshot returned from the `setMembers` call that removed it.
|
|
3771
|
+
*
|
|
3772
|
+
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
3773
|
+
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
3774
|
+
* any preemption) for an empty array or duplicate ids.
|
|
3775
|
+
*
|
|
3776
|
+
* For a selection that changes over time (shift-click toggle, marquee
|
|
3777
|
+
* replace), prefer [[syncTransformMembers]] over hand-rolling the
|
|
3778
|
+
* live-session-vs-fresh-`editMany` branch, and subscribe to
|
|
3779
|
+
* `TransformSession.onCommit` as the single fold path for exit snapshots
|
|
3780
|
+
* and `TransformSession.onSettled` as the lifecycle signal for when the
|
|
3781
|
+
* session is over and why.
|
|
3782
|
+
*/
|
|
3783
|
+
editMany(measures, options) {
|
|
3784
|
+
this.assertAlive();
|
|
3785
|
+
if (measures.length === 0) throw new TypeError("td.editMany: measures must not be empty");
|
|
3786
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3787
|
+
for (const m of measures) {
|
|
3788
|
+
if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate control measure id "${m.id}"`);
|
|
3789
|
+
seenIds.add(m.id);
|
|
3790
|
+
}
|
|
3791
|
+
const preempt = this.guardAndPreempt();
|
|
3792
|
+
if (preempt) return preempt;
|
|
3793
|
+
const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
|
|
3794
|
+
const restoreOriginalToGraphics = () => {
|
|
3795
|
+
this.renderMeasuresToGraphics(this.lastRenderedMeasures);
|
|
3796
|
+
};
|
|
3797
|
+
const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
|
|
3798
|
+
const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
|
|
3799
|
+
const onClickAwayGraphicsPick = (event) => {
|
|
3800
|
+
this.dispatchHostPick(this.graphicsLayer, event);
|
|
3801
|
+
};
|
|
3802
|
+
const extraSettle = () => {
|
|
3803
|
+
this.activeTransformSessionRef = null;
|
|
3804
|
+
};
|
|
3805
|
+
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
3806
|
+
adapter: this.adapter,
|
|
3807
|
+
measures,
|
|
3808
|
+
graphicsLayer: this.graphicsLayer,
|
|
3809
|
+
previewLayer: this.previewLayer,
|
|
3810
|
+
handlesLayer: this.handlesLayer,
|
|
3811
|
+
graphicsStyle: this.graphicsStyle,
|
|
3812
|
+
signal: options?.signal,
|
|
3813
|
+
closeOnClickAway: options?.closeOnClickAway,
|
|
3814
|
+
interactionStyle: mergedInteractionStyle,
|
|
3815
|
+
onSession: this.trackTransformSession(options?.onSession),
|
|
3816
|
+
handleIdPrefix,
|
|
3817
|
+
sizeAnchor: options?.sizeAnchor,
|
|
3818
|
+
restoreOriginalToGraphics,
|
|
3819
|
+
isOnGraphicsLayer,
|
|
3820
|
+
onSettled,
|
|
3821
|
+
onClickAwayGraphicsPick
|
|
3822
|
+
}), (handle) => {
|
|
3823
|
+
this.activeTransform = handle;
|
|
3824
|
+
return () => {
|
|
3825
|
+
if (this.activeTransform === handle) this.activeTransform = null;
|
|
3826
|
+
};
|
|
3827
|
+
}, extraSettle);
|
|
3828
|
+
}
|
|
3829
|
+
/**
|
|
3830
|
+
* One-call selection-sync: mutate the live group transform's member set in
|
|
3831
|
+
* place, or start a fresh `editMany` when no transform is live (#191). This
|
|
3832
|
+
* owns the "live session → `setMembers`, else → `editMany`" branch that every
|
|
3833
|
+
* host otherwise hand-rolls against `activeTransformSession`.
|
|
3834
|
+
*
|
|
3835
|
+
* The facade knows session liveness synchronously — `activeTransformSessionRef`
|
|
3836
|
+
* is cleared inside the settle path, before control returns to host code — so
|
|
3837
|
+
* unlike a host's own `activeTransformSession ? setMembers : editMany` check,
|
|
3838
|
+
* this can never observe a stale ref pointing at an already-settled session.
|
|
3839
|
+
* The settled-race fall-through hosts had to hand-roll for that gap is owned
|
|
3840
|
+
* here instead: a `setMembers` call that (implausibly) still returns
|
|
3841
|
+
* `undefined` falls through to a fresh `editMany` rather than silently
|
|
3842
|
+
* dropping the selection.
|
|
3843
|
+
*
|
|
3844
|
+
* `measures: []` closes any live session (committing every member) and is a
|
|
3845
|
+
* no-op while idle. With a live session, `options` is ignored — session-scoped
|
|
3846
|
+
* state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
|
|
3847
|
+
* `setMembers`'s contract; `options` applies only when this call starts a
|
|
3848
|
+
* fresh `editMany`. Exit snapshots are delivered via the session's
|
|
3849
|
+
* `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
|
|
3850
|
+
* including an Escape / preempt / destroy abort the host did not itself
|
|
3851
|
+
* initiate — is observed via the session's `onSettled` (#194); together
|
|
3852
|
+
* `onCommit` and `onSettled` make the underlying `editMany` promise
|
|
3853
|
+
* irrelevant to hosts. That promise is owned by the facade; its abort
|
|
3854
|
+
* rejections are swallowed here (non-abort errors surface as unhandled
|
|
3855
|
+
* rejections). Throws `TypeError` synchronously on duplicate ids, from
|
|
3856
|
+
* either path. Starting a fresh transform preempts any other in-flight
|
|
3857
|
+
* draw/edit exactly like `editMany`.
|
|
3858
|
+
*/
|
|
3859
|
+
syncTransformMembers(measures, options) {
|
|
3860
|
+
this.assertAlive();
|
|
3861
|
+
const session = this.activeTransformSessionRef;
|
|
3862
|
+
if (session) {
|
|
3863
|
+
if (session.setMembers(measures) !== void 0) return;
|
|
3864
|
+
}
|
|
3865
|
+
if (measures.length === 0) return;
|
|
3866
|
+
this.editMany([...measures], options).catch(ignoreAbort);
|
|
2609
3867
|
}
|
|
2610
3868
|
/**
|
|
2611
3869
|
* Subscribe to picks on committed control measures.
|
|
@@ -2673,6 +3931,15 @@ var TacticalDraw = class {
|
|
|
2673
3931
|
return this.activeInteraction.session;
|
|
2674
3932
|
}
|
|
2675
3933
|
/**
|
|
3934
|
+
* The live `editMany` group-transform session, or `null` while idle (and
|
|
3935
|
+
* during any other interaction). Separate from `activeSession` because
|
|
3936
|
+
* `TransformSession` isn't part of its `DrawSession | EditSession` union —
|
|
3937
|
+
* see `activeTransformSessionRef`. Safe to read after `destroy()`.
|
|
3938
|
+
*/
|
|
3939
|
+
get activeTransformSession() {
|
|
3940
|
+
return this.activeTransformSessionRef;
|
|
3941
|
+
}
|
|
3942
|
+
/**
|
|
2676
3943
|
* Tear down the façade. Releases only layer slots the façade created; host
|
|
2677
3944
|
* supplied layer ids are left untouched. Safe to call multiple times. After
|
|
2678
3945
|
* the first call every public method throws `TacticalDrawDestroyedError`.
|
|
@@ -2682,6 +3949,8 @@ var TacticalDraw = class {
|
|
|
2682
3949
|
this.destroyed = true;
|
|
2683
3950
|
if (this.activeInteraction.isActive) {
|
|
2684
3951
|
this.activeEdit = null;
|
|
3952
|
+
this.activeTransform = null;
|
|
3953
|
+
this.activeTransformSessionRef = null;
|
|
2685
3954
|
this.activeInteraction.cancel("destroyed");
|
|
2686
3955
|
} else this.activeInteraction.clear();
|
|
2687
3956
|
this.adapter.offViewChange(this.handleViewChange);
|
|
@@ -2701,7 +3970,8 @@ var TacticalDraw = class {
|
|
|
2701
3970
|
* from a host pick handler observes a settled session.
|
|
2702
3971
|
*/
|
|
2703
3972
|
routePick(layerId, event) {
|
|
2704
|
-
|
|
3973
|
+
const excluded = this.excludedInteractionIds();
|
|
3974
|
+
if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
|
|
2705
3975
|
this.deferredGraphicsPick = event;
|
|
2706
3976
|
return;
|
|
2707
3977
|
}
|
|
@@ -2740,9 +4010,19 @@ var TacticalDraw = class {
|
|
|
2740
4010
|
return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
|
|
2741
4011
|
}
|
|
2742
4012
|
/**
|
|
2743
|
-
* Resolve handle slots across built-in → façade →
|
|
2744
|
-
* per-property shallow merge. Explicit `undefined`
|
|
2745
|
-
* layer unsets the corresponding lower-priority field
|
|
4013
|
+
* Resolve handle slots (plus `boxOutline`) across built-in → façade →
|
|
4014
|
+
* per-call with per-slot, per-property shallow merge. Explicit `undefined`
|
|
4015
|
+
* at a higher-priority layer unsets the corresponding lower-priority field
|
|
4016
|
+
* (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
|
|
4017
|
+
* `guide` layer-style path) — the edit controller reads it off
|
|
4018
|
+
* `properties.style` on the box outline `LineString` (ADR-0017).
|
|
4019
|
+
*
|
|
4020
|
+
* `boxPadding` is a scalar, not a mergeable style object, so it resolves
|
|
4021
|
+
* separately with the same façade → per-call precedence and the same
|
|
4022
|
+
* explicit-undefined-wins rule: a `boxPadding` key present at a
|
|
4023
|
+
* higher-priority layer (even set to `undefined`) wins over a lower layer's
|
|
4024
|
+
* value. There is no built-in `boxPadding` — an absent value at every layer
|
|
4025
|
+
* falls through to the controllers' own `?? 0` default.
|
|
2746
4026
|
*
|
|
2747
4027
|
* Returns `undefined` only when no slot would carry any value — keeps the
|
|
2748
4028
|
* downstream "no style on the feature" path intact for tests that assert
|
|
@@ -2754,6 +4034,10 @@ var TacticalDraw = class {
|
|
|
2754
4034
|
const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
|
|
2755
4035
|
if (merged) out[slot] = merged;
|
|
2756
4036
|
}
|
|
4037
|
+
const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
|
|
4038
|
+
if (boxOutline) out.boxOutline = boxOutline;
|
|
4039
|
+
const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
|
|
4040
|
+
if (typeof boxPadding === "number") out.boxPadding = boxPadding;
|
|
2757
4041
|
return out;
|
|
2758
4042
|
}
|
|
2759
4043
|
assertAlive() {
|
|
@@ -2770,10 +4054,11 @@ const BUILT_IN_GRAPHICS_STYLE = {
|
|
|
2770
4054
|
strokeWidth: 2
|
|
2771
4055
|
};
|
|
2772
4056
|
/**
|
|
2773
|
-
* Built-in defaults for the interaction-affordance slots
|
|
2774
|
-
*
|
|
2775
|
-
*
|
|
2776
|
-
*
|
|
4057
|
+
* Built-in defaults for the interaction-affordance slots (ADR-0006, extended
|
|
4058
|
+
* for the ADR-0017 transform box). The scale (corner) grips and the rotate
|
|
4059
|
+
* grip are white-filled with the same blue stroke as the box outline, so the
|
|
4060
|
+
* box reads as one coherent affordance. Vertex/midpoint keep their existing
|
|
4061
|
+
* white/gray family.
|
|
2777
4062
|
*/
|
|
2778
4063
|
const BUILT_IN_INTERACTION_STYLE = {
|
|
2779
4064
|
guide: {
|
|
@@ -2781,6 +4066,10 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2781
4066
|
strokeWidth: 1,
|
|
2782
4067
|
strokeDash: [6, 4]
|
|
2783
4068
|
},
|
|
4069
|
+
boxOutline: {
|
|
4070
|
+
strokeColor: "#4f7cff",
|
|
4071
|
+
strokeWidth: 1
|
|
4072
|
+
},
|
|
2784
4073
|
vertexHandle: {
|
|
2785
4074
|
pointRadius: 6,
|
|
2786
4075
|
fillColor: "#ffffff",
|
|
@@ -2793,21 +4082,23 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2793
4082
|
strokeColor: "#6b7280",
|
|
2794
4083
|
strokeWidth: 1.5
|
|
2795
4084
|
},
|
|
2796
|
-
|
|
2797
|
-
pointRadius:
|
|
2798
|
-
fillColor: "#
|
|
2799
|
-
strokeColor: "#
|
|
4085
|
+
scaleHandle: {
|
|
4086
|
+
pointRadius: 5,
|
|
4087
|
+
fillColor: "#ffffff",
|
|
4088
|
+
strokeColor: "#4f7cff",
|
|
4089
|
+
strokeWidth: 2
|
|
2800
4090
|
},
|
|
2801
4091
|
rotateHandle: {
|
|
2802
|
-
pointRadius:
|
|
2803
|
-
fillColor: "#
|
|
2804
|
-
strokeColor: "#
|
|
4092
|
+
pointRadius: 5,
|
|
4093
|
+
fillColor: "#ffffff",
|
|
4094
|
+
strokeColor: "#4f7cff",
|
|
4095
|
+
strokeWidth: 2
|
|
2805
4096
|
}
|
|
2806
4097
|
};
|
|
2807
4098
|
const HANDLE_SLOTS = [
|
|
2808
4099
|
"vertexHandle",
|
|
2809
4100
|
"midpointHandle",
|
|
2810
|
-
"
|
|
4101
|
+
"scaleHandle",
|
|
2811
4102
|
"rotateHandle"
|
|
2812
4103
|
];
|
|
2813
4104
|
/**
|
|
@@ -2852,4 +4143,22 @@ function resolveSlot(adapter, supplied, owned) {
|
|
|
2852
4143
|
return id;
|
|
2853
4144
|
}
|
|
2854
4145
|
//#endregion
|
|
2855
|
-
|
|
4146
|
+
//#region src/world-wrap.ts
|
|
4147
|
+
/**
|
|
4148
|
+
* Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
|
|
4149
|
+
*
|
|
4150
|
+
* Map engines that wrap pointer longitudes into [-180, 180] (OpenLayers'
|
|
4151
|
+
* `toLonLat`, MapLibre's globe unproject) make positions jump ~360° when a
|
|
4152
|
+
* gesture crosses the antimeridian. Adapters use this to keep delivered
|
|
4153
|
+
* longitudes continuous with the view center, and to project coordinates on
|
|
4154
|
+
* the world copy the view is actually looking at.
|
|
4155
|
+
*/
|
|
4156
|
+
function nearestWorldCopyLng(lng, refLng) {
|
|
4157
|
+
return lng + 360 * Math.round((refLng - lng) / 360);
|
|
4158
|
+
}
|
|
4159
|
+
/** `nearestWorldCopyLng` applied to a lon/lat position. */
|
|
4160
|
+
function nearestWorldCopyPosition(position, refLng) {
|
|
4161
|
+
return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
|
|
4162
|
+
}
|
|
4163
|
+
//#endregion
|
|
4164
|
+
export { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, tryControlMeasureIdFromFeature };
|