@orbat-mapper/tactical-draw 0.2.0-alpha.3 → 0.2.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +249 -25
- package/dist/index.mjs +1383 -351
- 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.
|
|
@@ -773,6 +773,82 @@ function roundControlPoints(points) {
|
|
|
773
773
|
return points.map(roundControlPoint);
|
|
774
774
|
}
|
|
775
775
|
//#endregion
|
|
776
|
+
//#region src/tactical-draw/guide.ts
|
|
777
|
+
/**
|
|
778
|
+
* Stable preview-layer ids and emission rules for the rubber-band guide
|
|
779
|
+
* (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
|
|
780
|
+
* single styling rule across both interactions.
|
|
781
|
+
*/
|
|
782
|
+
const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
|
|
783
|
+
const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
|
|
784
|
+
const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
|
|
785
|
+
/**
|
|
786
|
+
* A kind is fixed-length when its draw rule pins the slot count
|
|
787
|
+
* (`canonicalPointCount`) or its metadata declares `min === max`. The
|
|
788
|
+
* generator preview is already authoritative for these, so the rubber-band
|
|
789
|
+
* guide adds no information.
|
|
790
|
+
*/
|
|
791
|
+
function isFixedLength(metadata) {
|
|
792
|
+
if (metadata.rule?.canonicalPointCount !== void 0) return true;
|
|
793
|
+
const min = metadata.minCoordinates;
|
|
794
|
+
const max = metadata.maxCoordinates;
|
|
795
|
+
return typeof min === "number" && typeof max === "number" && min === max;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Guide is mechanically suppressed for kinds where it carries no information:
|
|
799
|
+
* point kinds and fixed-length kinds. Independent of the host's `guide`
|
|
800
|
+
* opt-out.
|
|
801
|
+
*/
|
|
802
|
+
function shouldSuppressGuide(metadata) {
|
|
803
|
+
if (metadata.geometry === "point") return true;
|
|
804
|
+
if (metadata.rule?.showGuide === true) return false;
|
|
805
|
+
return isFixedLength(metadata);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Build the guide LineString features for a point list.
|
|
809
|
+
* - Open polyline through `points` once `points.length >= 2`.
|
|
810
|
+
* - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
|
|
811
|
+
*
|
|
812
|
+
* Callers are responsible for the opt-out (`guide: false`) and the suppression
|
|
813
|
+
* check via [[shouldSuppressGuide]] — this function only emits geometry.
|
|
814
|
+
*/
|
|
815
|
+
function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
|
|
816
|
+
const features = [];
|
|
817
|
+
const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
|
|
818
|
+
if (spine.length >= 2) {
|
|
819
|
+
const open = {
|
|
820
|
+
type: "Feature",
|
|
821
|
+
id: PREVIEW_GUIDE_OPEN_ID,
|
|
822
|
+
geometry: {
|
|
823
|
+
type: "LineString",
|
|
824
|
+
coordinates: spine.slice()
|
|
825
|
+
},
|
|
826
|
+
properties: {
|
|
827
|
+
part: "guide",
|
|
828
|
+
segment: "open"
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
features.push(open);
|
|
832
|
+
}
|
|
833
|
+
const alreadyClosed = spine.length >= 2 && spine[0][0] === spine[spine.length - 1][0] && spine[0][1] === spine[spine.length - 1][1];
|
|
834
|
+
if (isAreaKind && spine.length >= 3 && !alreadyClosed) {
|
|
835
|
+
const closing = {
|
|
836
|
+
type: "Feature",
|
|
837
|
+
id: PREVIEW_GUIDE_CLOSING_ID,
|
|
838
|
+
geometry: {
|
|
839
|
+
type: "LineString",
|
|
840
|
+
coordinates: [spine[spine.length - 1], spine[0]]
|
|
841
|
+
},
|
|
842
|
+
properties: {
|
|
843
|
+
part: "guide",
|
|
844
|
+
segment: "closing"
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
features.push(closing);
|
|
848
|
+
}
|
|
849
|
+
return features;
|
|
850
|
+
}
|
|
851
|
+
//#endregion
|
|
776
852
|
//#region src/tactical-draw/draw-controller.ts
|
|
777
853
|
const PREVIEW_CM_ID$1 = "__td_preview__";
|
|
778
854
|
const PREVIEW_CURSOR$1 = "crosshair";
|
|
@@ -790,7 +866,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
|
|
|
790
866
|
* kinds need no host UI.
|
|
791
867
|
*/
|
|
792
868
|
function runFixedLengthDraw(options) {
|
|
793
|
-
const { adapter, kind, previewLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, sizeAnchor, onSettled } = options;
|
|
869
|
+
const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
|
|
794
870
|
const metadata = getControlMeasureMetadata(kind);
|
|
795
871
|
const rule = metadata.rule;
|
|
796
872
|
const min = metadata.minCoordinates;
|
|
@@ -800,6 +876,8 @@ function runFixedLengthDraw(options) {
|
|
|
800
876
|
else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
|
|
801
877
|
else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
|
|
802
878
|
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
879
|
+
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
880
|
+
const isAreaKind = metadata.geometry === "area";
|
|
803
881
|
const committed = [];
|
|
804
882
|
let rubberBand;
|
|
805
883
|
let ctx;
|
|
@@ -824,11 +902,15 @@ function runFixedLengthDraw(options) {
|
|
|
824
902
|
const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
|
|
825
903
|
if (raw.length === 0) {
|
|
826
904
|
ctx.writeLayer(previewLayer, []);
|
|
905
|
+
ctx.writeLayer(guideLayer, []);
|
|
827
906
|
return;
|
|
828
907
|
}
|
|
829
908
|
const previewThreshold = rule?.minimumPreviewPoints ?? required;
|
|
830
909
|
const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
|
|
831
910
|
ctx.writeLayer(previewLayer, features);
|
|
911
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
|
|
912
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
|
|
913
|
+
ctx.writeLayer(guideLayer, guideFeatures);
|
|
832
914
|
}
|
|
833
915
|
function rescaleOnViewChange() {
|
|
834
916
|
if (!measureUsesAdapterResolution({
|
|
@@ -856,7 +938,7 @@ function runFixedLengthDraw(options) {
|
|
|
856
938
|
return runInteraction({
|
|
857
939
|
adapter,
|
|
858
940
|
signal,
|
|
859
|
-
layers: [previewLayer],
|
|
941
|
+
layers: [previewLayer, guideLayer],
|
|
860
942
|
onViewChange: rescaleOnViewChange,
|
|
861
943
|
onSettled,
|
|
862
944
|
attach(c) {
|
|
@@ -915,79 +997,6 @@ function outlinePreview$1(points) {
|
|
|
915
997
|
return features;
|
|
916
998
|
}
|
|
917
999
|
//#endregion
|
|
918
|
-
//#region src/tactical-draw/guide.ts
|
|
919
|
-
/**
|
|
920
|
-
* Stable preview-layer ids and emission rules for the rubber-band guide
|
|
921
|
-
* (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
|
|
922
|
-
* single styling rule across both interactions.
|
|
923
|
-
*/
|
|
924
|
-
const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
|
|
925
|
-
const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
|
|
926
|
-
const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
|
|
927
|
-
/**
|
|
928
|
-
* A kind is fixed-length when its draw rule pins the slot count
|
|
929
|
-
* (`canonicalPointCount`) or its metadata declares `min === max`. The
|
|
930
|
-
* generator preview is already authoritative for these, so the rubber-band
|
|
931
|
-
* guide adds no information.
|
|
932
|
-
*/
|
|
933
|
-
function isFixedLength(metadata) {
|
|
934
|
-
if (metadata.rule?.canonicalPointCount !== void 0) return true;
|
|
935
|
-
const min = metadata.minCoordinates;
|
|
936
|
-
const max = metadata.maxCoordinates;
|
|
937
|
-
return typeof min === "number" && typeof max === "number" && min === max;
|
|
938
|
-
}
|
|
939
|
-
/**
|
|
940
|
-
* Guide is mechanically suppressed for kinds where it carries no information:
|
|
941
|
-
* point kinds and fixed-length kinds. Independent of the host's `guide`
|
|
942
|
-
* opt-out.
|
|
943
|
-
*/
|
|
944
|
-
function shouldSuppressGuide(metadata) {
|
|
945
|
-
return metadata.geometry === "point" || isFixedLength(metadata);
|
|
946
|
-
}
|
|
947
|
-
/**
|
|
948
|
-
* Build the guide LineString features for a point list.
|
|
949
|
-
* - Open polyline through `points` once `points.length >= 2`.
|
|
950
|
-
* - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
|
|
951
|
-
*
|
|
952
|
-
* Callers are responsible for the opt-out (`guide: false`) and the suppression
|
|
953
|
-
* check via [[shouldSuppressGuide]] — this function only emits geometry.
|
|
954
|
-
*/
|
|
955
|
-
function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
|
|
956
|
-
const features = [];
|
|
957
|
-
const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
|
|
958
|
-
if (spine.length >= 2) {
|
|
959
|
-
const open = {
|
|
960
|
-
type: "Feature",
|
|
961
|
-
id: PREVIEW_GUIDE_OPEN_ID,
|
|
962
|
-
geometry: {
|
|
963
|
-
type: "LineString",
|
|
964
|
-
coordinates: spine.slice()
|
|
965
|
-
},
|
|
966
|
-
properties: {
|
|
967
|
-
part: "guide",
|
|
968
|
-
segment: "open"
|
|
969
|
-
}
|
|
970
|
-
};
|
|
971
|
-
features.push(open);
|
|
972
|
-
}
|
|
973
|
-
if (isAreaKind && spine.length >= 3) {
|
|
974
|
-
const closing = {
|
|
975
|
-
type: "Feature",
|
|
976
|
-
id: PREVIEW_GUIDE_CLOSING_ID,
|
|
977
|
-
geometry: {
|
|
978
|
-
type: "LineString",
|
|
979
|
-
coordinates: [spine[spine.length - 1], spine[0]]
|
|
980
|
-
},
|
|
981
|
-
properties: {
|
|
982
|
-
part: "guide",
|
|
983
|
-
segment: "closing"
|
|
984
|
-
}
|
|
985
|
-
};
|
|
986
|
-
features.push(closing);
|
|
987
|
-
}
|
|
988
|
-
return features;
|
|
989
|
-
}
|
|
990
|
-
//#endregion
|
|
991
1000
|
//#region src/tactical-draw/draw-controller-variable.ts
|
|
992
1001
|
const PREVIEW_CM_ID = "__td_preview__";
|
|
993
1002
|
const PREVIEW_CURSOR = "crosshair";
|
|
@@ -1006,6 +1015,7 @@ function runVariableLengthDraw(options) {
|
|
|
1006
1015
|
const max = metadata.maxCoordinates;
|
|
1007
1016
|
const rule = metadata.rule;
|
|
1008
1017
|
const trailingFixed = rule?.trailingFixedSlots ?? 0;
|
|
1018
|
+
const closedRing = rule?.closedRing ?? false;
|
|
1009
1019
|
const ruleMin = rule?.minimumUserPoints;
|
|
1010
1020
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
1011
1021
|
const isAreaKind = metadata.geometry === "area";
|
|
@@ -1097,20 +1107,20 @@ function runVariableLengthDraw(options) {
|
|
|
1097
1107
|
if (ctx.settled) return;
|
|
1098
1108
|
const pos = roundControlPoint(event.coordinate);
|
|
1099
1109
|
const lastSpineIdx = committed.length - 1 - trailingFixed;
|
|
1110
|
+
const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
|
|
1100
1111
|
for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
|
|
1101
|
-
if (i
|
|
1112
|
+
if (finishIndices.includes(i) && canCommitNow()) ctx.commit(buildSnapshot);
|
|
1102
1113
|
return;
|
|
1103
1114
|
}
|
|
1104
|
-
if (lastSpineIdx >= 0 && canCommitNow()) {
|
|
1105
|
-
const
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
}
|
|
1115
|
+
if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
|
|
1116
|
+
const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
|
|
1117
|
+
if (!finishPixel) continue;
|
|
1118
|
+
const tol = mapEventHitTolerance(event.originalEvent);
|
|
1119
|
+
const dx = event.pixel[0] - finishPixel[0];
|
|
1120
|
+
const dy = event.pixel[1] - finishPixel[1];
|
|
1121
|
+
if (dx * dx + dy * dy <= tol * tol) {
|
|
1122
|
+
ctx.commit(buildSnapshot);
|
|
1123
|
+
return;
|
|
1114
1124
|
}
|
|
1115
1125
|
}
|
|
1116
1126
|
if (typeof max === "number" && committed.length >= max) return;
|
|
@@ -1250,63 +1260,14 @@ function outlinePreview(points) {
|
|
|
1250
1260
|
//#endregion
|
|
1251
1261
|
//#region src/tactical-draw/handles.ts
|
|
1252
1262
|
/**
|
|
1253
|
-
* Compute the anchor used by translate and rotate handles. The anchor is the
|
|
1254
|
-
* centroid (arithmetic mean) of the control-point list — stable, kind
|
|
1255
|
-
* agnostic, and well-defined for any non-empty point set.
|
|
1256
|
-
*/
|
|
1257
|
-
function computeHandleAnchor(controlPoints) {
|
|
1258
|
-
if (controlPoints.length === 0) return null;
|
|
1259
|
-
let sx = 0;
|
|
1260
|
-
let sy = 0;
|
|
1261
|
-
for (const p of controlPoints) {
|
|
1262
|
-
sx += p[0];
|
|
1263
|
-
sy += p[1];
|
|
1264
|
-
}
|
|
1265
|
-
return [sx / controlPoints.length, sy / controlPoints.length];
|
|
1266
|
-
}
|
|
1267
|
-
/**
|
|
1268
|
-
* How far past the outermost control point the rotate grip sits. `1.3` places
|
|
1269
|
-
* it 30% beyond the farthest vertex, clearing the geometry while staying
|
|
1270
|
-
* proportional to the shape's size.
|
|
1271
|
-
*/
|
|
1272
|
-
const ROTATE_HANDLE_REACH_FACTOR = 1.3;
|
|
1273
|
-
/**
|
|
1274
|
-
* Position of the rotate grip. Unlike the translate handle — which sits on the
|
|
1275
|
-
* centroid pivot — the rotate handle needs a lever arm, so it is offset from
|
|
1276
|
-
* the centroid along the ray toward the control point farthest from it, just
|
|
1277
|
-
* outside the shape. Anchoring to a *material* point (rather than the
|
|
1278
|
-
* axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
|
|
1279
|
-
* grip orbits with the shape as it rotates, since the farthest vertex rotates
|
|
1280
|
-
* with it. Falls back to the centroid when every point coincides (no
|
|
1281
|
-
* well-defined direction) or the list is empty.
|
|
1282
|
-
*/
|
|
1283
|
-
function computeRotateHandlePosition(controlPoints) {
|
|
1284
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1285
|
-
if (!anchor) return null;
|
|
1286
|
-
let farthest = null;
|
|
1287
|
-
let maxDistSq = 0;
|
|
1288
|
-
for (const p of controlPoints) {
|
|
1289
|
-
const dx = p[0] - anchor[0];
|
|
1290
|
-
const dy = p[1] - anchor[1];
|
|
1291
|
-
const distSq = dx * dx + dy * dy;
|
|
1292
|
-
if (distSq > maxDistSq) {
|
|
1293
|
-
maxDistSq = distSq;
|
|
1294
|
-
farthest = p;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
if (!farthest) return anchor;
|
|
1298
|
-
return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
|
|
1299
|
-
}
|
|
1300
|
-
/**
|
|
1301
1263
|
* Axis-aligned bounding-box center of the control points — the midpoint of
|
|
1302
|
-
* `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1264
|
+
* `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
|
|
1265
|
+
* `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
|
|
1266
|
+
* the first paint), matching mainstream drawing tools' "turn about the visual
|
|
1267
|
+
* middle" convention. Returns `null` for an empty list.
|
|
1306
1268
|
*
|
|
1307
|
-
* Exported because the rotation pivot is *not* a handle
|
|
1308
|
-
*
|
|
1309
|
-
* controller computes it directly at drag-start. See ADR-0016.
|
|
1269
|
+
* Exported because the rotation pivot is *not* a handle — the edit controller
|
|
1270
|
+
* consults it directly. See ADR-0016/ADR-0017.
|
|
1310
1271
|
*/
|
|
1311
1272
|
function computeBoundingBoxCenter(controlPoints) {
|
|
1312
1273
|
if (controlPoints.length === 0) return null;
|
|
@@ -1332,26 +1293,26 @@ function segmentMidpoint(a, b) {
|
|
|
1332
1293
|
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1333
1294
|
}
|
|
1334
1295
|
/**
|
|
1335
|
-
* The single source of truth for
|
|
1336
|
-
* `EditMode → which handles` gating (ADR-0016). Pure and
|
|
1337
|
-
* positions are lonLat. Handles are emitted in **pick
|
|
1338
|
-
* `[vertices…, midpoints
|
|
1339
|
-
* first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1296
|
+
* The single source of truth for reshape-handle placement and the
|
|
1297
|
+
* `EditMode → which handles` gating (ADR-0016, narrowed by ADR-0017). Pure and
|
|
1298
|
+
* projection-free: positions are lonLat. Handles are emitted in **pick
|
|
1299
|
+
* priority order** — `[vertices…, midpoints…]` — so `pickHandle` can return
|
|
1300
|
+
* the first within tolerance and preserve "vertex wins over a pixel-closer
|
|
1340
1301
|
* midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
|
|
1341
1302
|
* projections of this set; the drag controller seeds gestures from it too.
|
|
1342
1303
|
*
|
|
1343
1304
|
* Emits handles only for modes present in `modes`:
|
|
1344
1305
|
* - `reshape`: vertex handle at every control point + midpoint handle on
|
|
1345
1306
|
* every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
|
|
1346
|
-
* - `delete`: vertex handles only (tapped to remove), no
|
|
1347
|
-
* - `translate`: a single translate handle at the centroid anchor;
|
|
1348
|
-
* - `rotate`: a single rotate grip offset outside the shape.
|
|
1307
|
+
* - `delete`: vertex handles only (tapped to remove), no midpoints.
|
|
1349
1308
|
*
|
|
1350
|
-
* `
|
|
1351
|
-
* are
|
|
1309
|
+
* `transform` mode never contributes handles here — the box's corner/rotate
|
|
1310
|
+
* grips are pixel-space and placed by `transform-box.ts`, then rendered as
|
|
1311
|
+
* `Handle`s of kind `scale`/`rotate` by the edit controller. Returns `[]` for
|
|
1312
|
+
* an empty control-point list.
|
|
1352
1313
|
*/
|
|
1353
1314
|
function buildHandleSet(controlPoints, modes, options = {}) {
|
|
1354
|
-
const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
|
|
1315
|
+
const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
|
|
1355
1316
|
const handles = [];
|
|
1356
1317
|
const wantsReshape = modes.includes("reshape");
|
|
1357
1318
|
if (wantsReshape || modes.includes("delete")) {
|
|
@@ -1361,37 +1322,24 @@ function buildHandleSet(controlPoints, modes, options = {}) {
|
|
|
1361
1322
|
position: [...controlPoints[i]]
|
|
1362
1323
|
});
|
|
1363
1324
|
if (wantsReshape && includeMidpoints) {
|
|
1364
|
-
const
|
|
1365
|
-
for (let i = 0; i <
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1325
|
+
const edgeCount = closedRing && trailingFixedSlots === 0 && controlPoints.length >= 3 ? controlPoints.length : controlPoints.length - 1 - trailingFixedSlots;
|
|
1326
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
1327
|
+
const end = (i + 1) % controlPoints.length;
|
|
1328
|
+
handles.push({
|
|
1329
|
+
kind: "midpoint",
|
|
1330
|
+
index: i,
|
|
1331
|
+
position: segmentMidpoint(controlPoints[i], controlPoints[end])
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1370
1334
|
}
|
|
1371
1335
|
}
|
|
1372
|
-
if (modes.includes("translate")) {
|
|
1373
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1374
|
-
if (anchor) handles.push({
|
|
1375
|
-
kind: "translate",
|
|
1376
|
-
index: 0,
|
|
1377
|
-
position: anchor
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
if (modes.includes("rotate")) {
|
|
1381
|
-
const rotatePos = computeRotateHandlePosition(controlPoints);
|
|
1382
|
-
if (rotatePos) handles.push({
|
|
1383
|
-
kind: "rotate",
|
|
1384
|
-
index: 0,
|
|
1385
|
-
position: rotatePos
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
1336
|
return handles;
|
|
1389
1337
|
}
|
|
1390
1338
|
function handleFeatureId(prefix, kind, index) {
|
|
1391
1339
|
switch (kind) {
|
|
1392
1340
|
case "vertex": return `${prefix}:handle-vertex:${index}`;
|
|
1393
1341
|
case "midpoint": return `${prefix}:handle-midpoint:${index}`;
|
|
1394
|
-
case "
|
|
1342
|
+
case "scale": return `${prefix}:handle-scale:${index}`;
|
|
1395
1343
|
case "rotate": return `${prefix}:handle-rotate`;
|
|
1396
1344
|
}
|
|
1397
1345
|
}
|
|
@@ -1399,16 +1347,18 @@ function handleStyleFor(kind, interactionStyle) {
|
|
|
1399
1347
|
switch (kind) {
|
|
1400
1348
|
case "vertex": return interactionStyle?.vertexHandle;
|
|
1401
1349
|
case "midpoint": return interactionStyle?.midpointHandle;
|
|
1402
|
-
case "
|
|
1350
|
+
case "scale": return interactionStyle?.scaleHandle;
|
|
1403
1351
|
case "rotate": return interactionStyle?.rotateHandle;
|
|
1404
1352
|
}
|
|
1405
1353
|
}
|
|
1406
1354
|
/**
|
|
1407
1355
|
* Project a handle set into the GeoJSON features that belong on the handles
|
|
1408
|
-
* layer — the render projection of `buildHandleSet` (ADR-0016)
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1356
|
+
* layer — the render projection of `buildHandleSet` (ADR-0016), also reused by
|
|
1357
|
+
* the edit controller to render the transform box's corner/rotate `Handle`s
|
|
1358
|
+
* (ADR-0017). Stable `featureIdPrefix`-namespaced ids let multiple edits share
|
|
1359
|
+
* a handles layer without colliding; per-slot `interactionStyle` rides on
|
|
1360
|
+
* `properties.style`. Pure: handle positions are copied, never aliased into
|
|
1361
|
+
* the output geometry.
|
|
1412
1362
|
*/
|
|
1413
1363
|
function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
1414
1364
|
return handles.map((handle) => {
|
|
@@ -1431,26 +1381,142 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
|
1431
1381
|
};
|
|
1432
1382
|
});
|
|
1433
1383
|
}
|
|
1434
|
-
function pixelDistance(a, b) {
|
|
1384
|
+
function pixelDistance$2(a, b) {
|
|
1435
1385
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1436
1386
|
}
|
|
1437
1387
|
/**
|
|
1438
1388
|
* The pick projection of `buildHandleSet` (ADR-0016): the first handle within
|
|
1439
1389
|
* `tolerance` pixels of `pixel`, tested in the set's priority order. Because
|
|
1440
|
-
* `buildHandleSet` orders vertices before midpoints
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1390
|
+
* `buildHandleSet` orders vertices before midpoints, "first within tolerance"
|
|
1391
|
+
* preserves the legacy "vertex wins over a pixel-closer midpoint" behaviour.
|
|
1392
|
+
* Returns the full `Handle` so the drag controller can seed a gesture from its
|
|
1393
|
+
* `position` without recomputing placement. Handles whose position fails to
|
|
1394
|
+
* project (off-screen / degenerate projection) are skipped. Returns `null`
|
|
1395
|
+
* when nothing is within tolerance.
|
|
1446
1396
|
*/
|
|
1447
1397
|
function pickHandle(handles, pixel, tolerance, toPixel) {
|
|
1448
1398
|
for (const handle of handles) {
|
|
1449
1399
|
const projected = toPixel(handle.position);
|
|
1450
|
-
if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
|
|
1400
|
+
if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
|
|
1451
1401
|
}
|
|
1452
1402
|
return null;
|
|
1453
1403
|
}
|
|
1404
|
+
function rotate(x, y, angle) {
|
|
1405
|
+
const cos = Math.cos(angle);
|
|
1406
|
+
const sin = Math.sin(angle);
|
|
1407
|
+
return [x * cos - y * sin, x * sin + y * cos];
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
|
|
1411
|
+
* Rotates the points by `-angle` about their centroid, takes the axis-aligned
|
|
1412
|
+
* bounding box in that de-rotated frame, then re-expresses the result as an
|
|
1413
|
+
* oriented box at `angle` — so the box always has exactly the requested tilt,
|
|
1414
|
+
* sized to just contain the (de-rotated) points. Half-dimensions are floored
|
|
1415
|
+
* at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
|
|
1416
|
+
* pickable box. Returns `null` for an empty list — there is nothing to fit.
|
|
1417
|
+
*
|
|
1418
|
+
* `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
|
|
1419
|
+
* the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
|
|
1420
|
+
* `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
|
|
1421
|
+
* the rendered geometry. Everything downstream that reads a fitted box
|
|
1422
|
+
* (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
|
|
1423
|
+
* `halfWidth`/`halfHeight` alone, so the padding flows through to corner
|
|
1424
|
+
* placement, the rotate grip, and picking automatically — no other function
|
|
1425
|
+
* needs to know about it.
|
|
1426
|
+
*/
|
|
1427
|
+
function fitTransformBox(pixels, angle, padding = 0) {
|
|
1428
|
+
if (pixels.length === 0) return null;
|
|
1429
|
+
let cx = 0;
|
|
1430
|
+
let cy = 0;
|
|
1431
|
+
for (const p of pixels) {
|
|
1432
|
+
cx += p[0];
|
|
1433
|
+
cy += p[1];
|
|
1434
|
+
}
|
|
1435
|
+
cx /= pixels.length;
|
|
1436
|
+
cy /= pixels.length;
|
|
1437
|
+
let minX = Infinity;
|
|
1438
|
+
let minY = Infinity;
|
|
1439
|
+
let maxX = -Infinity;
|
|
1440
|
+
let maxY = -Infinity;
|
|
1441
|
+
for (const p of pixels) {
|
|
1442
|
+
const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
|
|
1443
|
+
if (rx < minX) minX = rx;
|
|
1444
|
+
if (rx > maxX) maxX = rx;
|
|
1445
|
+
if (ry < minY) minY = ry;
|
|
1446
|
+
if (ry > maxY) maxY = ry;
|
|
1447
|
+
}
|
|
1448
|
+
const localCenterX = (minX + maxX) / 2;
|
|
1449
|
+
const localCenterY = (minY + maxY) / 2;
|
|
1450
|
+
const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
|
|
1451
|
+
const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
|
|
1452
|
+
const [ox, oy] = rotate(localCenterX, localCenterY, angle);
|
|
1453
|
+
return {
|
|
1454
|
+
center: [cx + ox, cy + oy],
|
|
1455
|
+
halfWidth,
|
|
1456
|
+
halfHeight,
|
|
1457
|
+
angle
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
|
|
1462
|
+
* bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
|
|
1463
|
+
* `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
|
|
1464
|
+
* and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
|
|
1465
|
+
* anchor is always the opposite corner, `(index + 2) % 4`.
|
|
1466
|
+
*/
|
|
1467
|
+
function boxCorners(box) {
|
|
1468
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1469
|
+
return [
|
|
1470
|
+
[-halfWidth, -halfHeight],
|
|
1471
|
+
[halfWidth, -halfHeight],
|
|
1472
|
+
[halfWidth, halfHeight],
|
|
1473
|
+
[-halfWidth, halfHeight]
|
|
1474
|
+
].map(([x, y]) => {
|
|
1475
|
+
const [rx, ry] = rotate(x, y, angle);
|
|
1476
|
+
return [center[0] + rx, center[1] + ry];
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
|
|
1481
|
+
* midpoint, along the box's local "up" direction, so the grip stays glued to
|
|
1482
|
+
* the box (rotates with it) rather than floating at a fixed screen offset.
|
|
1483
|
+
*/
|
|
1484
|
+
function rotateGripPixel(box) {
|
|
1485
|
+
const { center, halfHeight, angle } = box;
|
|
1486
|
+
const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
|
|
1487
|
+
return [center[0] + rx, center[1] + ry];
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Point-in-oriented-rectangle test, used to start a body (translate) drag
|
|
1491
|
+
* when the pointer goes down inside the box but not on a corner/rotate grip.
|
|
1492
|
+
* De-rotates `pixel` into the box's local frame about `box.center` and
|
|
1493
|
+
* compares against the half-extents.
|
|
1494
|
+
*/
|
|
1495
|
+
function pointInBox(box, pixel) {
|
|
1496
|
+
const { center, halfWidth, halfHeight, angle } = box;
|
|
1497
|
+
const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
|
|
1498
|
+
return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
|
|
1499
|
+
}
|
|
1500
|
+
function pixelDistance$1(a, b) {
|
|
1501
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Pick what part of the box, if any, `pixel` hits — corners first, then the
|
|
1505
|
+
* rotate grip, then plain containment (body drag). Corners and the rotate
|
|
1506
|
+
* grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
|
|
1507
|
+
* click just outside the box never starts a body drag. Returns `null` when
|
|
1508
|
+
* nothing matches.
|
|
1509
|
+
*/
|
|
1510
|
+
function pickBoxHandle(box, pixel, tolerance) {
|
|
1511
|
+
const corners = boxCorners(box);
|
|
1512
|
+
for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
|
|
1513
|
+
kind: "scale",
|
|
1514
|
+
index: i
|
|
1515
|
+
};
|
|
1516
|
+
if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
|
|
1517
|
+
if (pointInBox(box, pixel)) return { kind: "body" };
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1454
1520
|
//#endregion
|
|
1455
1521
|
//#region src/tactical-draw/handle-drag.ts
|
|
1456
1522
|
function clonePoints(points) {
|
|
@@ -1464,17 +1530,8 @@ function translatePoints(base, dx, dy) {
|
|
|
1464
1530
|
return out;
|
|
1465
1531
|
});
|
|
1466
1532
|
}
|
|
1467
|
-
function
|
|
1468
|
-
return Math.
|
|
1469
|
-
}
|
|
1470
|
-
/**
|
|
1471
|
-
* Angle of `pixel` around the rotation pivot, measured in screen space so it
|
|
1472
|
-
* matches what the user sees and does. Falls back to lon/lat when the pivot
|
|
1473
|
-
* cannot be projected — exceedingly rare for an on-screen edit.
|
|
1474
|
-
*/
|
|
1475
|
-
function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
|
|
1476
|
-
if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
|
|
1477
|
-
return angleFromAnchor(pivot, projection.fromPixel(pixel));
|
|
1533
|
+
function pixelDistance(a, b) {
|
|
1534
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1478
1535
|
}
|
|
1479
1536
|
/**
|
|
1480
1537
|
* Rotate `points` by `angle` about the pivot in screen space — project each
|
|
@@ -1532,7 +1589,7 @@ function begun(kind, initialBase, move) {
|
|
|
1532
1589
|
* caller's working copy.
|
|
1533
1590
|
*/
|
|
1534
1591
|
function beginHandleDrag(args) {
|
|
1535
|
-
const { hit,
|
|
1592
|
+
const { hit, points, projection, rule } = args;
|
|
1536
1593
|
const { fromPixel } = projection;
|
|
1537
1594
|
const preDragPoints = clonePoints(points);
|
|
1538
1595
|
const snap = (next, activePointIndex) => rule ? rule.transform({
|
|
@@ -1542,45 +1599,103 @@ function beginHandleDrag(args) {
|
|
|
1542
1599
|
}) : next;
|
|
1543
1600
|
const dragOnePoint = (base, activeIndex) => {
|
|
1544
1601
|
const move = (pixel) => {
|
|
1545
|
-
return {
|
|
1546
|
-
points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
|
|
1547
|
-
rotateGripOverride: null
|
|
1548
|
-
};
|
|
1602
|
+
return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
|
|
1549
1603
|
};
|
|
1550
1604
|
return move;
|
|
1551
1605
|
};
|
|
1552
1606
|
if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1607
|
+
const insertIndex = hit.index + 1;
|
|
1608
|
+
const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
|
|
1609
|
+
const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
|
|
1610
|
+
const insertedBase = [
|
|
1611
|
+
...preDragPoints.slice(0, insertIndex),
|
|
1612
|
+
mid,
|
|
1613
|
+
...preDragPoints.slice(insertIndex)
|
|
1614
|
+
];
|
|
1615
|
+
return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
|
|
1616
|
+
}
|
|
1617
|
+
/** No corner drag may scale below this factor — guards against reflection/collapse through the anchor. */
|
|
1618
|
+
const MIN_SCALE_FACTOR = .05;
|
|
1619
|
+
/**
|
|
1620
|
+
* Begin a transform-box drag: uniform corner scale, box-center rotate, or
|
|
1621
|
+
* body translate. Mirrors `beginHandleDrag`'s shape (a `move`/`end` FSM seeded
|
|
1622
|
+
* from the pre-drag points) but keyed on `BoxDragKind` instead of `HandleKind`,
|
|
1623
|
+
* and additionally threading the box itself through so the caller can paint
|
|
1624
|
+
* the live box without re-fitting from projected geometry every frame.
|
|
1625
|
+
*/
|
|
1626
|
+
function beginBoxDrag(args) {
|
|
1627
|
+
const { kind, downPixel, points, projection, box, cornerIndex } = args;
|
|
1628
|
+
const { toPixel, fromPixel } = projection;
|
|
1629
|
+
const preDragPoints = clonePoints(points);
|
|
1630
|
+
if (kind === "scale") {
|
|
1631
|
+
const index = cornerIndex ?? 0;
|
|
1632
|
+
const anchor = boxCorners(box)[(index + 2) % 4];
|
|
1633
|
+
const downDist = pixelDistance(downPixel, anchor);
|
|
1634
|
+
const move = (pixel) => {
|
|
1635
|
+
const dist = pixelDistance(pixel, anchor);
|
|
1636
|
+
const rawFactor = downDist === 0 ? 1 : dist / downDist;
|
|
1637
|
+
const factor = Math.max(rawFactor, MIN_SCALE_FACTOR);
|
|
1567
1638
|
return {
|
|
1568
|
-
points:
|
|
1569
|
-
|
|
1639
|
+
points: preDragPoints.map((p) => {
|
|
1640
|
+
const px = toPixel(p);
|
|
1641
|
+
if (!px) return [...p];
|
|
1642
|
+
const ll = fromPixel([anchor[0] + (px[0] - anchor[0]) * factor, anchor[1] + (px[1] - anchor[1]) * factor]);
|
|
1643
|
+
const out = [ll[0], ll[1]];
|
|
1644
|
+
for (let k = 2; k < p.length; k++) out.push(p[k]);
|
|
1645
|
+
return out;
|
|
1646
|
+
}),
|
|
1647
|
+
box: {
|
|
1648
|
+
center: [anchor[0] + (box.center[0] - anchor[0]) * factor, anchor[1] + (box.center[1] - anchor[1]) * factor],
|
|
1649
|
+
halfWidth: box.halfWidth * factor,
|
|
1650
|
+
halfHeight: box.halfHeight * factor,
|
|
1651
|
+
angle: box.angle
|
|
1652
|
+
}
|
|
1570
1653
|
};
|
|
1571
|
-
}
|
|
1654
|
+
};
|
|
1655
|
+
return {
|
|
1656
|
+
kind,
|
|
1657
|
+
move,
|
|
1658
|
+
end: (pixel) => move(pixel).points
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
if (kind === "rotate") {
|
|
1662
|
+
const pivotPixel = box.center;
|
|
1663
|
+
const pivot = fromPixel(pivotPixel);
|
|
1664
|
+
const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
|
|
1665
|
+
const move = (pixel) => {
|
|
1666
|
+
const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
|
|
1667
|
+
return {
|
|
1668
|
+
points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
|
|
1669
|
+
box: {
|
|
1670
|
+
center: [...pivotPixel],
|
|
1671
|
+
halfWidth: box.halfWidth,
|
|
1672
|
+
halfHeight: box.halfHeight,
|
|
1673
|
+
angle: box.angle + angle
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
};
|
|
1677
|
+
return {
|
|
1678
|
+
kind,
|
|
1679
|
+
move,
|
|
1680
|
+
end: (pixel) => move(pixel).points
|
|
1681
|
+
};
|
|
1572
1682
|
}
|
|
1573
|
-
const
|
|
1574
|
-
const
|
|
1575
|
-
|
|
1576
|
-
const startGrip = hit.position;
|
|
1577
|
-
return begun("rotate", preDragPoints, (pixel) => {
|
|
1578
|
-
const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
|
|
1683
|
+
const origin = fromPixel(downPixel);
|
|
1684
|
+
const move = (pixel) => {
|
|
1685
|
+
const cur = fromPixel(pixel);
|
|
1579
1686
|
return {
|
|
1580
|
-
points:
|
|
1581
|
-
|
|
1687
|
+
points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
|
|
1688
|
+
box: {
|
|
1689
|
+
...box,
|
|
1690
|
+
center: [box.center[0] + (pixel[0] - downPixel[0]), box.center[1] + (pixel[1] - downPixel[1])]
|
|
1691
|
+
}
|
|
1582
1692
|
};
|
|
1583
|
-
}
|
|
1693
|
+
};
|
|
1694
|
+
return {
|
|
1695
|
+
kind,
|
|
1696
|
+
move,
|
|
1697
|
+
end: (pixel) => move(pixel).points
|
|
1698
|
+
};
|
|
1584
1699
|
}
|
|
1585
1700
|
//#endregion
|
|
1586
1701
|
//#region src/tactical-draw/edit-session.ts
|
|
@@ -1602,8 +1717,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
|
|
|
1602
1717
|
const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
|
|
1603
1718
|
/**
|
|
1604
1719
|
* Internal test seam — simulate a completed rotate gesture by passing an angle
|
|
1605
|
-
* in radians. Every control point rotates around the
|
|
1606
|
-
*
|
|
1720
|
+
* in radians. Every control point rotates around the transform box's center
|
|
1721
|
+
* (falling back to the control-points' bounding-box center when no box has
|
|
1722
|
+
* been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
|
|
1723
|
+
* the same delta a live rotate box-drag would (ADR-0017).
|
|
1607
1724
|
*
|
|
1608
1725
|
* @internal
|
|
1609
1726
|
*/
|
|
@@ -1624,6 +1741,78 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
|
|
|
1624
1741
|
*/
|
|
1625
1742
|
const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
1626
1743
|
//#endregion
|
|
1744
|
+
//#region src/tactical-draw/box-features.ts
|
|
1745
|
+
/**
|
|
1746
|
+
* Rendering helpers shared by the single-measure edit controller
|
|
1747
|
+
* (`edit-controller.ts`) and the group transform controller
|
|
1748
|
+
* (`transform-controller.ts`): both paint the same `TransformBox` shape —
|
|
1749
|
+
* corner scale handles, a rotate grip, and a closed-ring outline — so the
|
|
1750
|
+
* projection from pure box geometry (`transform-box.ts`) to lonLat `Handle`s
|
|
1751
|
+
* / GeoJSON features lives in one place instead of two copies drifting apart.
|
|
1752
|
+
* See ADR-0017.
|
|
1753
|
+
*/
|
|
1754
|
+
/** Walk a GeoJSON `Geometry`'s coordinate arrays, pushing every `Position` onto `out`. */
|
|
1755
|
+
function collectFeaturePositions(geometry, out) {
|
|
1756
|
+
switch (geometry.type) {
|
|
1757
|
+
case "Point":
|
|
1758
|
+
out.push(geometry.coordinates);
|
|
1759
|
+
break;
|
|
1760
|
+
case "MultiPoint":
|
|
1761
|
+
case "LineString":
|
|
1762
|
+
for (const p of geometry.coordinates) out.push(p);
|
|
1763
|
+
break;
|
|
1764
|
+
case "MultiLineString":
|
|
1765
|
+
case "Polygon":
|
|
1766
|
+
for (const ring of geometry.coordinates) for (const p of ring) out.push(p);
|
|
1767
|
+
break;
|
|
1768
|
+
case "MultiPolygon":
|
|
1769
|
+
for (const poly of geometry.coordinates) for (const ring of poly) for (const p of ring) out.push(p);
|
|
1770
|
+
break;
|
|
1771
|
+
case "GeometryCollection":
|
|
1772
|
+
for (const g of geometry.geometries) collectFeaturePositions(g, out);
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Project a `TransformBox`'s 4 corner scale handles and rotate grip into
|
|
1778
|
+
* lonLat `Handle`s via `fromPixel` — the render projection of the box's
|
|
1779
|
+
* pixel-space geometry (`boxCorners` / `rotateGripPixel`), reused by both
|
|
1780
|
+
* controllers so box-handle placement can't drift between single-measure and
|
|
1781
|
+
* group transforms.
|
|
1782
|
+
*/
|
|
1783
|
+
function boxToHandles(box, fromPixel) {
|
|
1784
|
+
const handles = boxCorners(box).map((corner, index) => ({
|
|
1785
|
+
kind: "scale",
|
|
1786
|
+
index,
|
|
1787
|
+
position: fromPixel(corner)
|
|
1788
|
+
}));
|
|
1789
|
+
handles.push({
|
|
1790
|
+
kind: "rotate",
|
|
1791
|
+
index: 0,
|
|
1792
|
+
position: fromPixel(rotateGripPixel(box))
|
|
1793
|
+
});
|
|
1794
|
+
return handles;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Build the closed `LineString` outline feature for `box`, styled from
|
|
1798
|
+
* `style` (delivered per-feature via `properties.style`, as the adapters
|
|
1799
|
+
* already honor for line features). `featureId` is the caller's stable id —
|
|
1800
|
+
* `${prefix}:box-outline` for a single-measure or group box, or
|
|
1801
|
+
* `${prefix}:member-outline:{measureId}` for a group member's own outline.
|
|
1802
|
+
*/
|
|
1803
|
+
function boxOutlineFeature(box, fromPixel, featureId, style) {
|
|
1804
|
+
const corners = boxCorners(box).map((c) => fromPixel(c));
|
|
1805
|
+
return {
|
|
1806
|
+
type: "Feature",
|
|
1807
|
+
id: featureId,
|
|
1808
|
+
geometry: {
|
|
1809
|
+
type: "LineString",
|
|
1810
|
+
coordinates: [...corners, corners[0]]
|
|
1811
|
+
},
|
|
1812
|
+
properties: style ? { style: { ...style } } : {}
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
//#endregion
|
|
1627
1816
|
//#region src/tactical-draw/edit-controller.ts
|
|
1628
1817
|
/**
|
|
1629
1818
|
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
@@ -1632,27 +1821,32 @@ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
|
1632
1821
|
* dirty tracking; the constant lives in one place so future slices can lift
|
|
1633
1822
|
* it to an option without touching call sites.
|
|
1634
1823
|
*/
|
|
1635
|
-
const DIRTY_EPSILON = 1e-9;
|
|
1636
|
-
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
1824
|
+
const DIRTY_EPSILON$1 = 1e-9;
|
|
1825
|
+
const HANDLE_HIT_TOLERANCE_PX$1 = 12;
|
|
1826
|
+
/**
|
|
1827
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
1828
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
1829
|
+
* click, not a gesture: it is not committed (geometry restored, no
|
|
1830
|
+
* `EditChangeEvent`, `dirty` untouched) but the click-away listener is
|
|
1831
|
+
* suppressed so the click doesn't close the edit. Corner/rotate drags commit
|
|
1832
|
+
* even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
|
|
1833
|
+
*/
|
|
1834
|
+
const CLICK_SLOP_PX$1 = 3;
|
|
1637
1835
|
const ALLOWED_EDIT_MODES = new Set([
|
|
1638
1836
|
"reshape",
|
|
1639
|
-
"
|
|
1640
|
-
"rotate",
|
|
1837
|
+
"transform",
|
|
1641
1838
|
"delete"
|
|
1642
1839
|
]);
|
|
1643
1840
|
/**
|
|
1644
|
-
* Drive an interactive edit against a `MapAdapter`. Implements the
|
|
1645
|
-
*
|
|
1841
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
1842
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
1646
1843
|
*
|
|
1647
1844
|
* - lift-original (remove from graphics layer) + working copy
|
|
1648
|
-
* - preview + handle rendering
|
|
1845
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
1846
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
1649
1847
|
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
1650
1848
|
* - default click-away close policy
|
|
1651
1849
|
* - close / abort / signal / preempt / destroy paths
|
|
1652
|
-
*
|
|
1653
|
-
* Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
|
|
1654
|
-
* gesture binding lands with the change-event spec in #49 and the
|
|
1655
|
-
* translate/rotate handle set in #50.
|
|
1656
1850
|
*/
|
|
1657
1851
|
function runEdit(opts) {
|
|
1658
1852
|
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
@@ -1662,6 +1856,7 @@ function runEdit(opts) {
|
|
|
1662
1856
|
const rule = metadata.rule;
|
|
1663
1857
|
const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
|
|
1664
1858
|
const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
|
|
1859
|
+
const closedRing = rule?.closedRing ?? false;
|
|
1665
1860
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
1666
1861
|
const isAreaKind = metadata.geometry === "area";
|
|
1667
1862
|
const requestedModes = normaliseModes(initialModes);
|
|
@@ -1692,12 +1887,55 @@ function runEdit(opts) {
|
|
|
1692
1887
|
*/
|
|
1693
1888
|
let pendingModes = null;
|
|
1694
1889
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
1890
|
+
/**
|
|
1891
|
+
* The transform box's rotation, in radians, session-scoped (ADR-0017 decision
|
|
1892
|
+
* #2): starts at `0` (axis-aligned) and accumulates every committed rotate
|
|
1893
|
+
* gesture's delta, so the box stays glued to the shape across gestures. Never
|
|
1894
|
+
* persisted on the measure; a fresh edit always starts back at `0`.
|
|
1895
|
+
*/
|
|
1896
|
+
let boxAngle = 0;
|
|
1897
|
+
/**
|
|
1898
|
+
* The transform box fitted around the *rendered* working-copy geometry
|
|
1899
|
+
* (pixel space), or `null` when nothing projected this frame. Re-fitted
|
|
1900
|
+
* after every committed gesture, every reshape-drag paint, and on
|
|
1901
|
+
* `onViewChange`; painted rigidly from the live drag's own snapshot instead
|
|
1902
|
+
* while a box gesture (`activeBoxDrag`) is in flight.
|
|
1903
|
+
*/
|
|
1904
|
+
let box = null;
|
|
1905
|
+
/** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
|
|
1906
|
+
let lastPreviewFeatures = [];
|
|
1695
1907
|
let ctx;
|
|
1696
1908
|
let driver;
|
|
1697
1909
|
const dragProjection = {
|
|
1698
1910
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
1699
1911
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
1700
1912
|
};
|
|
1913
|
+
/**
|
|
1914
|
+
* Fit the transform box around `features`' rendered geometry: every
|
|
1915
|
+
* coordinate is projected to pixels via the pointer driver and
|
|
1916
|
+
* `fitTransformBox`'d at the current `boxAngle`. `null` when nothing
|
|
1917
|
+
* projects (e.g. the working copy renders no features this frame).
|
|
1918
|
+
*/
|
|
1919
|
+
function fitBoxFromFeatures(features) {
|
|
1920
|
+
const positions = [];
|
|
1921
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
1922
|
+
const pixels = [];
|
|
1923
|
+
for (const p of positions) {
|
|
1924
|
+
const px = driver.toPixel(p);
|
|
1925
|
+
if (px) pixels.push(px);
|
|
1926
|
+
}
|
|
1927
|
+
return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
|
|
1931
|
+
* in flight — that drag paints the box rigidly from its own snapshot
|
|
1932
|
+
* (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
|
|
1933
|
+
* doesn't fight its own paint.
|
|
1934
|
+
*/
|
|
1935
|
+
function refitBox() {
|
|
1936
|
+
if (activeBoxDrag) return;
|
|
1937
|
+
box = fitBoxFromFeatures(lastPreviewFeatures);
|
|
1938
|
+
}
|
|
1701
1939
|
function liftOriginalFromGraphics() {
|
|
1702
1940
|
if (!wasOnGraphicsLayer) return;
|
|
1703
1941
|
const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
@@ -1718,37 +1956,48 @@ function runEdit(opts) {
|
|
|
1718
1956
|
features = [];
|
|
1719
1957
|
}
|
|
1720
1958
|
ctx.writeLayer(previewLayer, features);
|
|
1721
|
-
|
|
1959
|
+
lastPreviewFeatures = features;
|
|
1960
|
+
refitBox();
|
|
1961
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
|
|
1962
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
|
|
1722
1963
|
ctx.writeLayer(guideLayer, guideFeatures);
|
|
1723
1964
|
}
|
|
1724
1965
|
function rescaleOnViewChange() {
|
|
1725
|
-
if (
|
|
1966
|
+
if (ctx.settled) return;
|
|
1967
|
+
if (measureUsesAdapterResolution({
|
|
1726
1968
|
kind: measure.kind,
|
|
1727
1969
|
options: workingOptions
|
|
1728
|
-
}))
|
|
1729
|
-
|
|
1970
|
+
})) renderPreview();
|
|
1971
|
+
else refitBox();
|
|
1972
|
+
renderHandlesLayer();
|
|
1730
1973
|
}
|
|
1731
1974
|
function renderHandlesLayer() {
|
|
1732
1975
|
if (ctx.settled) return;
|
|
1733
|
-
|
|
1976
|
+
const features = toFeatures(buildHandleSet(workingPoints, modes, {
|
|
1734
1977
|
includeMidpoints: midpointHandlesEnabled,
|
|
1735
|
-
trailingFixedSlots
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1978
|
+
trailingFixedSlots,
|
|
1979
|
+
closedRing
|
|
1980
|
+
}), handleIdPrefix, interactionStyle);
|
|
1981
|
+
if (modes.includes("transform")) {
|
|
1982
|
+
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
1983
|
+
if (liveBox) {
|
|
1984
|
+
features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
|
|
1985
|
+
features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1745
1988
|
ctx.writeLayer(handlesLayer, features);
|
|
1746
1989
|
}
|
|
1747
|
-
|
|
1990
|
+
/**
|
|
1991
|
+
* Builds the committed snapshot against explicit `options` rather than the
|
|
1992
|
+
* live `workingOptions` — used to render a gesture's "previous" snapshot
|
|
1993
|
+
* against pre-freeze options when a rotate has already merged its
|
|
1994
|
+
* orientation freeze into `workingOptions` by the time the commit runs.
|
|
1995
|
+
*/
|
|
1996
|
+
function rebuildRenderedWith(options) {
|
|
1748
1997
|
const working = {
|
|
1749
1998
|
...measure,
|
|
1750
1999
|
style: currentStyle,
|
|
1751
|
-
options
|
|
2000
|
+
options,
|
|
1752
2001
|
controlPoints: workingPoints.map((p) => [...p])
|
|
1753
2002
|
};
|
|
1754
2003
|
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
@@ -1757,8 +2006,11 @@ function runEdit(opts) {
|
|
|
1757
2006
|
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
1758
2007
|
};
|
|
1759
2008
|
}
|
|
2009
|
+
function rebuildRendered() {
|
|
2010
|
+
return rebuildRenderedWith(workingOptions);
|
|
2011
|
+
}
|
|
1760
2012
|
function recomputeDirty() {
|
|
1761
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
|
|
2013
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
|
|
1762
2014
|
}
|
|
1763
2015
|
function replaceWorkingPoints(next) {
|
|
1764
2016
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -1767,6 +2019,23 @@ function runEdit(opts) {
|
|
|
1767
2019
|
workingOptions = { ...next ?? {} };
|
|
1768
2020
|
}
|
|
1769
2021
|
/**
|
|
2022
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
2023
|
+
* anchor) to their current concrete values, merging any patch onto
|
|
2024
|
+
* `workingOptions` in place. Called the moment a rotate gesture starts (a
|
|
2025
|
+
* box-drag rotate hit, or the `commitRotate` test seam) — a rigid rotation
|
|
2026
|
+
* of `workingPoints` follows immediately after, so the freeze must land
|
|
2027
|
+
* first or the orientation-relative default would re-derive against the
|
|
2028
|
+
* rotated geometry (ADR-0023). No dirty/change-event side effects of its
|
|
2029
|
+
* own — the rotate gesture's own commit carries the frozen options.
|
|
2030
|
+
*/
|
|
2031
|
+
function freezeOrientationForRotate() {
|
|
2032
|
+
const patch = freezeOrientationOptions(measure.kind, workingPoints.map((p) => [...p]), workingOptions);
|
|
2033
|
+
if (patch) workingOptions = {
|
|
2034
|
+
...workingOptions ?? {},
|
|
2035
|
+
...patch
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
1770
2039
|
* Emit a completed gesture to every registered listener. The pre-gesture
|
|
1771
2040
|
* snapshot is provided as `previousRendered`; `commitGesture` has already
|
|
1772
2041
|
* mutated `workingPoints` and re-rendered preview / handle layers before
|
|
@@ -1832,19 +2101,20 @@ function runEdit(opts) {
|
|
|
1832
2101
|
* queued during the drag are applied after the listener emission (so the
|
|
1833
2102
|
* post-gesture render reflects the requested mode set).
|
|
1834
2103
|
*/
|
|
1835
|
-
function commitGesture(nextPoints) {
|
|
2104
|
+
function commitGesture(nextPoints, preGestureOptions) {
|
|
1836
2105
|
if (ctx.settled) return;
|
|
1837
2106
|
const previousPoints = workingPoints.map((p) => [...p]);
|
|
1838
|
-
const previousOptions = { ...workingOptions ?? {} };
|
|
2107
|
+
const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
|
|
1839
2108
|
const previousStyle = currentStyle;
|
|
1840
2109
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
1841
|
-
const previousRendered =
|
|
2110
|
+
const previousRendered = rebuildRenderedWith(previousOptions);
|
|
1842
2111
|
replaceWorkingPoints(nextPoints);
|
|
1843
2112
|
let currentRendered;
|
|
1844
2113
|
try {
|
|
1845
2114
|
currentRendered = rebuildRendered();
|
|
1846
2115
|
} catch (err) {
|
|
1847
2116
|
replaceWorkingPoints(previousPoints);
|
|
2117
|
+
if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
|
|
1848
2118
|
dragInFlight = false;
|
|
1849
2119
|
drainPendingModes();
|
|
1850
2120
|
throw err;
|
|
@@ -1934,16 +2204,23 @@ function runEdit(opts) {
|
|
|
1934
2204
|
}));
|
|
1935
2205
|
}
|
|
1936
2206
|
/**
|
|
1937
|
-
*
|
|
1938
|
-
* around the
|
|
2207
|
+
* Test seam for a rotate gesture: rotate every working point by `angle`
|
|
2208
|
+
* (radians) around the transform box's center — falling back to the
|
|
2209
|
+
* control-points' bounding-box center when no box has been fitted yet (e.g.
|
|
2210
|
+
* before the first paint). The rotation runs in screen space (see
|
|
1939
2211
|
* `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
|
|
1940
|
-
* latitude
|
|
2212
|
+
* latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
|
|
2213
|
+
* would (ADR-0017), so the box stays glued to the shape across seam calls.
|
|
1941
2214
|
*/
|
|
1942
2215
|
function commitRotate(angle) {
|
|
1943
2216
|
if (ctx.settled) return;
|
|
1944
|
-
const
|
|
2217
|
+
const preFreezeOptions = { ...workingOptions ?? {} };
|
|
2218
|
+
freezeOrientationForRotate();
|
|
2219
|
+
const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
|
|
2220
|
+
const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
|
|
1945
2221
|
if (!pivot) return;
|
|
1946
|
-
|
|
2222
|
+
boxAngle += angle;
|
|
2223
|
+
commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
|
|
1947
2224
|
}
|
|
1948
2225
|
function modesEqual(a, b) {
|
|
1949
2226
|
if (a.length !== b.length) return false;
|
|
@@ -2035,13 +2312,14 @@ function runEdit(opts) {
|
|
|
2035
2312
|
};
|
|
2036
2313
|
let activeDrag = null;
|
|
2037
2314
|
/**
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2315
|
+
* A live transform-box gesture (corner scale, box rotate, or body
|
|
2316
|
+
* translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
|
|
2317
|
+
* and is replaced on every `move()` tick; `renderHandlesLayer` paints
|
|
2318
|
+
* `lastSnapshot.box` instead of re-fitting while this is non-null, and
|
|
2319
|
+
* `preDragBox` lets a committed rotate compute the angle delta to fold into
|
|
2320
|
+
* `boxAngle`.
|
|
2043
2321
|
*/
|
|
2044
|
-
let
|
|
2322
|
+
let activeBoxDrag = null;
|
|
2045
2323
|
/**
|
|
2046
2324
|
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2047
2325
|
* exclusive `delete` mode). Deletion is
|
|
@@ -2080,73 +2358,149 @@ function runEdit(opts) {
|
|
|
2080
2358
|
}
|
|
2081
2359
|
const onDriverDown = (pixel, info) => {
|
|
2082
2360
|
if (ctx.settled || dragInFlight) return;
|
|
2083
|
-
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
2361
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
|
|
2084
2362
|
const hit = pickHandle(buildHandleSet(workingPoints, modes, {
|
|
2085
2363
|
includeMidpoints: midpointHandlesEnabled,
|
|
2086
|
-
trailingFixedSlots
|
|
2364
|
+
trailingFixedSlots,
|
|
2365
|
+
closedRing
|
|
2087
2366
|
}), pixel, tol, (lonLat) => driver.toPixel(lonLat));
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2090
|
-
|
|
2367
|
+
if (hit) {
|
|
2368
|
+
if (modes.includes("delete")) {
|
|
2369
|
+
if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
|
|
2370
|
+
deleteVertex(hit.index);
|
|
2371
|
+
suppressClickAway = true;
|
|
2372
|
+
}
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
|
|
2091
2376
|
deleteVertex(hit.index);
|
|
2092
2377
|
suppressClickAway = true;
|
|
2378
|
+
return;
|
|
2093
2379
|
}
|
|
2380
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2381
|
+
const { drag: fsm, initialPoints } = beginHandleDrag({
|
|
2382
|
+
hit,
|
|
2383
|
+
downPixel: pixel,
|
|
2384
|
+
points: preDragPoints,
|
|
2385
|
+
projection: dragProjection,
|
|
2386
|
+
rule
|
|
2387
|
+
});
|
|
2388
|
+
activeDrag = {
|
|
2389
|
+
fsm,
|
|
2390
|
+
preDragPoints
|
|
2391
|
+
};
|
|
2392
|
+
if (fsm.kind === "midpoint") {
|
|
2393
|
+
workingPoints.splice(0, workingPoints.length, ...initialPoints);
|
|
2394
|
+
renderPreview();
|
|
2395
|
+
renderHandlesLayer();
|
|
2396
|
+
}
|
|
2397
|
+
dragInFlight = true;
|
|
2398
|
+
driver.setDragPanEnabled(false);
|
|
2094
2399
|
return;
|
|
2095
2400
|
}
|
|
2096
|
-
if (
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2401
|
+
if (modes.includes("transform") && box) {
|
|
2402
|
+
const boxHit = pickBoxHandle(box, pixel, tol);
|
|
2403
|
+
if (boxHit) {
|
|
2404
|
+
const preDragOptions = { ...workingOptions ?? {} };
|
|
2405
|
+
if (boxHit.kind === "rotate") {
|
|
2406
|
+
freezeOrientationForRotate();
|
|
2407
|
+
renderPreview();
|
|
2408
|
+
renderHandlesLayer();
|
|
2409
|
+
}
|
|
2410
|
+
const preDragPoints = workingPoints.map((p) => [...p]);
|
|
2411
|
+
activeBoxDrag = {
|
|
2412
|
+
fsm: beginBoxDrag({
|
|
2413
|
+
kind: boxHit.kind,
|
|
2414
|
+
downPixel: pixel,
|
|
2415
|
+
points: preDragPoints,
|
|
2416
|
+
projection: dragProjection,
|
|
2417
|
+
box,
|
|
2418
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
2419
|
+
}),
|
|
2420
|
+
preDragPoints,
|
|
2421
|
+
preDragBox: box,
|
|
2422
|
+
downPixel: pixel,
|
|
2423
|
+
lastSnapshot: {
|
|
2424
|
+
points: preDragPoints,
|
|
2425
|
+
box
|
|
2426
|
+
},
|
|
2427
|
+
preDragOptions
|
|
2428
|
+
};
|
|
2429
|
+
dragInFlight = true;
|
|
2430
|
+
driver.setDragPanEnabled(false);
|
|
2431
|
+
}
|
|
2117
2432
|
}
|
|
2118
|
-
dragInFlight = true;
|
|
2119
|
-
driver.setDragPanEnabled(false);
|
|
2120
2433
|
};
|
|
2121
2434
|
function paintDragSnapshot(pixel) {
|
|
2122
2435
|
if (!activeDrag) return;
|
|
2123
2436
|
const snapshot = activeDrag.fsm.move(pixel);
|
|
2124
2437
|
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2125
|
-
|
|
2438
|
+
renderPreview();
|
|
2439
|
+
renderHandlesLayer();
|
|
2440
|
+
recomputeDirty();
|
|
2441
|
+
}
|
|
2442
|
+
function paintBoxDragSnapshot(pixel) {
|
|
2443
|
+
if (!activeBoxDrag) return;
|
|
2444
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
2445
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
2446
|
+
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2126
2447
|
renderPreview();
|
|
2127
2448
|
renderHandlesLayer();
|
|
2128
2449
|
recomputeDirty();
|
|
2129
2450
|
}
|
|
2130
2451
|
const onDriverMove = (pixel) => {
|
|
2131
|
-
if (ctx.settled
|
|
2132
|
-
paintDragSnapshot(pixel);
|
|
2452
|
+
if (ctx.settled) return;
|
|
2453
|
+
if (activeDrag) paintDragSnapshot(pixel);
|
|
2454
|
+
else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
2133
2455
|
};
|
|
2456
|
+
/** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
|
|
2457
|
+
function endDragBookkeeping() {
|
|
2458
|
+
driver.setDragPanEnabled(true);
|
|
2459
|
+
dragInFlight = false;
|
|
2460
|
+
drainPendingModes();
|
|
2461
|
+
}
|
|
2134
2462
|
const onDriverUp = (pixel) => {
|
|
2135
|
-
if (
|
|
2136
|
-
|
|
2463
|
+
if (activeDrag) {
|
|
2464
|
+
if (ctx.settled) {
|
|
2465
|
+
activeDrag = null;
|
|
2466
|
+
dragInFlight = false;
|
|
2467
|
+
driver.setDragPanEnabled(true);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const { fsm, preDragPoints } = activeDrag;
|
|
2137
2471
|
activeDrag = null;
|
|
2138
|
-
rotateHandleOverride = null;
|
|
2139
|
-
dragInFlight = false;
|
|
2140
2472
|
driver.setDragPanEnabled(true);
|
|
2473
|
+
const next = fsm.end(pixel);
|
|
2474
|
+
replaceWorkingPoints(preDragPoints);
|
|
2475
|
+
commitGesture(next);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
if (activeBoxDrag) {
|
|
2479
|
+
if (ctx.settled) {
|
|
2480
|
+
activeBoxDrag = null;
|
|
2481
|
+
dragInFlight = false;
|
|
2482
|
+
driver.setDragPanEnabled(true);
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
|
|
2486
|
+
activeBoxDrag = null;
|
|
2487
|
+
driver.setDragPanEnabled(true);
|
|
2488
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
2489
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
|
|
2490
|
+
replaceWorkingPoints(preDragPoints);
|
|
2491
|
+
renderPreview();
|
|
2492
|
+
renderHandlesLayer();
|
|
2493
|
+
recomputeDirty();
|
|
2494
|
+
endDragBookkeeping();
|
|
2495
|
+
suppressClickAway = true;
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
const finalSnapshot = fsm.move(pixel);
|
|
2499
|
+
replaceWorkingPoints(preDragPoints);
|
|
2500
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
2501
|
+
commitGesture(finalSnapshot.points, preDragOptions);
|
|
2141
2502
|
return;
|
|
2142
2503
|
}
|
|
2143
|
-
const { fsm, preDragPoints } = activeDrag;
|
|
2144
|
-
activeDrag = null;
|
|
2145
|
-
rotateHandleOverride = null;
|
|
2146
|
-
driver.setDragPanEnabled(true);
|
|
2147
|
-
const next = fsm.end(pixel);
|
|
2148
|
-
replaceWorkingPoints(preDragPoints);
|
|
2149
|
-
commitGesture(next);
|
|
2150
2504
|
};
|
|
2151
2505
|
let pickPreview;
|
|
2152
2506
|
let pickHandles;
|
|
@@ -2210,10 +2564,10 @@ function runEdit(opts) {
|
|
|
2210
2564
|
unsubDown();
|
|
2211
2565
|
unsubMove();
|
|
2212
2566
|
unsubUp();
|
|
2213
|
-
if (activeDrag !== null) {
|
|
2567
|
+
if (activeDrag !== null || activeBoxDrag !== null) {
|
|
2214
2568
|
driver.setDragPanEnabled(true);
|
|
2215
2569
|
activeDrag = null;
|
|
2216
|
-
|
|
2570
|
+
activeBoxDrag = null;
|
|
2217
2571
|
dragInFlight = false;
|
|
2218
2572
|
}
|
|
2219
2573
|
driver.dispose();
|
|
@@ -2241,12 +2595,17 @@ function runEdit(opts) {
|
|
|
2241
2595
|
}
|
|
2242
2596
|
};
|
|
2243
2597
|
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
|
|
2600
|
+
* throws `TypeError` synchronously for an unknown mode, collapses `delete` to
|
|
2601
|
+
* an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
|
|
2602
|
+
* Exported so the façade can hoist this validation *before* `guardAndPreempt`
|
|
2603
|
+
* — an invalid mode must not tear down whatever edit/draw is already active
|
|
2604
|
+
* (see the `td.edit` regression test for issue tracked alongside ADR-0017).
|
|
2605
|
+
*/
|
|
2244
2606
|
function normaliseModes(modes) {
|
|
2245
|
-
if (!modes || modes.length === 0) return ["reshape"];
|
|
2246
|
-
for (const m of modes) {
|
|
2247
|
-
if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2248
|
-
if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2249
|
-
}
|
|
2607
|
+
if (!modes || modes.length === 0) return ["reshape", "transform"];
|
|
2608
|
+
for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2250
2609
|
if (modes.includes("delete")) return ["delete"];
|
|
2251
2610
|
const seen = /* @__PURE__ */ new Set();
|
|
2252
2611
|
const out = [];
|
|
@@ -2274,6 +2633,531 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
|
2274
2633
|
return true;
|
|
2275
2634
|
}
|
|
2276
2635
|
//#endregion
|
|
2636
|
+
//#region src/tactical-draw/transform-controller.ts
|
|
2637
|
+
/**
|
|
2638
|
+
* Coordinate-space tolerance for `TransformSession.dirty` — mirrors
|
|
2639
|
+
* `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
|
|
2640
|
+
* than a shared import since the two controllers' dirty definitions may
|
|
2641
|
+
* diverge later: a group's dirty is "any member moved").
|
|
2642
|
+
*/
|
|
2643
|
+
const DIRTY_EPSILON = 1e-9;
|
|
2644
|
+
const HANDLE_HIT_TOLERANCE_PX = 12;
|
|
2645
|
+
/**
|
|
2646
|
+
* A body drag (drag-anywhere-inside-the-box translate) whose release pixel
|
|
2647
|
+
* lands within this many pixels of the down pixel is treated as a plain
|
|
2648
|
+
* click, not a gesture — same rationale as `edit-controller.ts`'s constant of
|
|
2649
|
+
* the same name.
|
|
2650
|
+
*/
|
|
2651
|
+
const CLICK_SLOP_PX = 3;
|
|
2652
|
+
/**
|
|
2653
|
+
* Drive an interactive group transform against a `MapAdapter` — the leaner,
|
|
2654
|
+
* multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
|
|
2655
|
+
* reshape, no per-vertex handles, no guide layer, no per-measure
|
|
2656
|
+
* `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
|
|
2657
|
+
* grip, drag-anywhere body translate), applied rigidly to every member's
|
|
2658
|
+
* control points at once.
|
|
2659
|
+
*
|
|
2660
|
+
* - lift every member off the graphics layer + per-member working copies
|
|
2661
|
+
* - render every member's preview to the shared preview layer each paint
|
|
2662
|
+
* - group box = fit over the union of every member's rendered pixel
|
|
2663
|
+
* coordinates at the session `boxAngle`; each member also gets its own
|
|
2664
|
+
* thin outline (its own fit at the same angle)
|
|
2665
|
+
* - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
|
|
2666
|
+
* contracts as `runEdit`, scoped to the group
|
|
2667
|
+
*/
|
|
2668
|
+
function runGroupTransform(opts) {
|
|
2669
|
+
const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
2670
|
+
const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
|
|
2671
|
+
const memberIds = new Set(measures.map((m) => m.id));
|
|
2672
|
+
let sizeAnchor = opts.sizeAnchor ?? "ground";
|
|
2673
|
+
const editStartPointsByMember = measures.map((m) => m.controlPoints.map((p) => [...p]));
|
|
2674
|
+
const workingPointsByMember = editStartPointsByMember.map((pts) => pts.map((p) => [...p]));
|
|
2675
|
+
const counts = workingPointsByMember.map((pts) => pts.length);
|
|
2676
|
+
/**
|
|
2677
|
+
* Per-member options patch, index-aligned with `measures`, applied on top of
|
|
2678
|
+
* the member's own `options` when building its working/rendered measure.
|
|
2679
|
+
* This exists solely to freeze orientation-derived option defaults (e.g.
|
|
2680
|
+
* Battle Position's echelon anchor) the moment a group rotate gesture
|
|
2681
|
+
* starts (ADR-0023) — there is no public per-member `setOptions` here, and
|
|
2682
|
+
* the "no per-measure setOptions" contract for the group session still
|
|
2683
|
+
* holds; a host cannot reach this array.
|
|
2684
|
+
*/
|
|
2685
|
+
let optionsOverrideByMember = measures.map(() => null);
|
|
2686
|
+
/** Effective options for member `i` against a given override array, merged onto its own options. */
|
|
2687
|
+
function effectiveOptionsWith(i, overrides) {
|
|
2688
|
+
const override = overrides[i];
|
|
2689
|
+
if (!override) return measures[i].options;
|
|
2690
|
+
return {
|
|
2691
|
+
...measures[i].options ?? {},
|
|
2692
|
+
...override
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
/** Effective options for member `i`: its own options merged with the *live* override. */
|
|
2696
|
+
function effectiveOptions(i) {
|
|
2697
|
+
return effectiveOptionsWith(i, optionsOverrideByMember);
|
|
2698
|
+
}
|
|
2699
|
+
/** The working measure for member `i` against a given override array, with live working points. */
|
|
2700
|
+
function workingMeasureForWith(i, overrides) {
|
|
2701
|
+
return {
|
|
2702
|
+
...measures[i],
|
|
2703
|
+
options: effectiveOptionsWith(i, overrides),
|
|
2704
|
+
controlPoints: workingPointsByMember[i].map((p) => [...p])
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
/** The working measure for member `i`: original measure, live effective options, live working points. */
|
|
2708
|
+
function workingMeasureFor(i) {
|
|
2709
|
+
return workingMeasureForWith(i, optionsOverrideByMember);
|
|
2710
|
+
}
|
|
2711
|
+
let dirty = false;
|
|
2712
|
+
/**
|
|
2713
|
+
* Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
|
|
2714
|
+
* even when every control point still matches edit-start — mirrors
|
|
2715
|
+
* `edit-controller.ts`'s flag of the same name.
|
|
2716
|
+
*/
|
|
2717
|
+
let nonGeometryDirty = false;
|
|
2718
|
+
let dragInFlight = false;
|
|
2719
|
+
/** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
|
|
2720
|
+
let suppressClickAway = false;
|
|
2721
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
2722
|
+
/** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
|
|
2723
|
+
let boxAngle = 0;
|
|
2724
|
+
/** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
|
|
2725
|
+
let groupBox = null;
|
|
2726
|
+
/** Each member's own fitted box (same angle as `groupBox`), index-aligned with `measures`. */
|
|
2727
|
+
let memberBoxes = measures.map(() => null);
|
|
2728
|
+
/** The features written to the preview layer for each member on the last paint. */
|
|
2729
|
+
let lastPreviewFeaturesByMember = measures.map(() => []);
|
|
2730
|
+
let ctx;
|
|
2731
|
+
let driver;
|
|
2732
|
+
const dragProjection = {
|
|
2733
|
+
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
2734
|
+
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
2735
|
+
};
|
|
2736
|
+
function liftOriginalsFromGraphics() {
|
|
2737
|
+
measures.forEach((measure, i) => {
|
|
2738
|
+
if (!wasOnGraphicsLayer[i]) return;
|
|
2739
|
+
const ids = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
2740
|
+
if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
function pixelsFor(features) {
|
|
2744
|
+
const positions = [];
|
|
2745
|
+
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
2746
|
+
const pixels = [];
|
|
2747
|
+
for (const p of positions) {
|
|
2748
|
+
const px = driver.toPixel(p);
|
|
2749
|
+
if (px) pixels.push(px);
|
|
2750
|
+
}
|
|
2751
|
+
return pixels;
|
|
2752
|
+
}
|
|
2753
|
+
/** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
|
|
2754
|
+
function currentAngle() {
|
|
2755
|
+
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Re-fit `groupBox` and every `memberBoxes[i]` from `lastPreviewFeaturesByMember`.
|
|
2759
|
+
* While a box gesture is in flight, `groupBox` itself is not touched here —
|
|
2760
|
+
* `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
|
|
2761
|
+
* (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
|
|
2762
|
+
* still re-fitted every paint at the drag's live angle, since they are a
|
|
2763
|
+
* derived display only (no self-fight risk).
|
|
2764
|
+
*/
|
|
2765
|
+
function refitBoxes() {
|
|
2766
|
+
const angle = currentAngle();
|
|
2767
|
+
const padding = interactionStyle?.boxPadding ?? 0;
|
|
2768
|
+
const allPixels = [];
|
|
2769
|
+
memberBoxes = lastPreviewFeaturesByMember.map((features) => {
|
|
2770
|
+
const pixels = pixelsFor(features);
|
|
2771
|
+
allPixels.push(...pixels);
|
|
2772
|
+
return fitTransformBox(pixels, angle, padding);
|
|
2773
|
+
});
|
|
2774
|
+
if (activeBoxDrag) return;
|
|
2775
|
+
groupBox = fitTransformBox(allPixels, boxAngle, padding);
|
|
2776
|
+
}
|
|
2777
|
+
function renderAllPreviews() {
|
|
2778
|
+
if (ctx.settled) return;
|
|
2779
|
+
const allFeatures = [];
|
|
2780
|
+
lastPreviewFeaturesByMember = measures.map((_measure, i) => {
|
|
2781
|
+
const working = workingMeasureFor(i);
|
|
2782
|
+
let features;
|
|
2783
|
+
try {
|
|
2784
|
+
features = renderMeasureForAdapter(adapter, working, renderOpts).features;
|
|
2785
|
+
} catch {
|
|
2786
|
+
features = [];
|
|
2787
|
+
}
|
|
2788
|
+
allFeatures.push(...features);
|
|
2789
|
+
return features;
|
|
2790
|
+
});
|
|
2791
|
+
ctx.writeLayer(previewLayer, allFeatures);
|
|
2792
|
+
refitBoxes();
|
|
2793
|
+
}
|
|
2794
|
+
function rescaleOnViewChange() {
|
|
2795
|
+
if (ctx.settled) return;
|
|
2796
|
+
if (measures.some((m) => measureUsesAdapterResolution({
|
|
2797
|
+
kind: m.kind,
|
|
2798
|
+
options: m.options
|
|
2799
|
+
}))) renderAllPreviews();
|
|
2800
|
+
else refitBoxes();
|
|
2801
|
+
renderHandlesLayer();
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
|
|
2805
|
+
* so the group reads as dashed per the reference design — unless the host
|
|
2806
|
+
* explicitly set their own `strokeDash`, which wins.
|
|
2807
|
+
*/
|
|
2808
|
+
function groupOutlineStyle() {
|
|
2809
|
+
const base = interactionStyle?.boxOutline;
|
|
2810
|
+
if (base?.strokeDash !== void 0) return base;
|
|
2811
|
+
return {
|
|
2812
|
+
...base ?? {},
|
|
2813
|
+
strokeDash: [4, 4]
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
function renderHandlesLayer() {
|
|
2817
|
+
if (ctx.settled) return;
|
|
2818
|
+
const features = [];
|
|
2819
|
+
const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
|
|
2820
|
+
if (liveGroupBox) {
|
|
2821
|
+
features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
|
|
2822
|
+
features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
2823
|
+
}
|
|
2824
|
+
measures.forEach((measure, i) => {
|
|
2825
|
+
const memberBox = memberBoxes[i];
|
|
2826
|
+
if (!memberBox) return;
|
|
2827
|
+
features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
|
|
2828
|
+
});
|
|
2829
|
+
ctx.writeLayer(handlesLayer, features);
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Builds every member's committed snapshot against a given override array
|
|
2833
|
+
* rather than the live `optionsOverrideByMember` — used to render a
|
|
2834
|
+
* gesture's pre-freeze "previous" snapshot when a rotate has already
|
|
2835
|
+
* mutated the live overrides by the time the commit runs.
|
|
2836
|
+
*/
|
|
2837
|
+
function rebuildRenderedAllWith(overrides) {
|
|
2838
|
+
return measures.map((_measure, i) => {
|
|
2839
|
+
const working = workingMeasureForWith(i, overrides);
|
|
2840
|
+
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
2841
|
+
return {
|
|
2842
|
+
measure: cloneControlMeasure(committed),
|
|
2843
|
+
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
2844
|
+
};
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
function rebuildRenderedAll() {
|
|
2848
|
+
return rebuildRenderedAllWith(optionsOverrideByMember);
|
|
2849
|
+
}
|
|
2850
|
+
function recomputeDirty() {
|
|
2851
|
+
dirty = nonGeometryDirty || workingPointsByMember.some((pts, i) => !pointsEqualWithinTolerance(pts, editStartPointsByMember[i], DIRTY_EPSILON));
|
|
2852
|
+
}
|
|
2853
|
+
/** Split `points` (the concatenation of every member's control points) back per member and write them into `workingPointsByMember`. */
|
|
2854
|
+
function applyConcatenatedPoints(points) {
|
|
2855
|
+
const rounded = roundControlPoints(points);
|
|
2856
|
+
let offset = 0;
|
|
2857
|
+
for (let i = 0; i < counts.length; i++) {
|
|
2858
|
+
const c = counts[i];
|
|
2859
|
+
const slice = rounded.slice(offset, offset + c);
|
|
2860
|
+
workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...slice);
|
|
2861
|
+
offset += c;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
function concatWorkingPoints() {
|
|
2865
|
+
const out = [];
|
|
2866
|
+
for (const pts of workingPointsByMember) out.push(...pts.map((p) => [...p]));
|
|
2867
|
+
return out;
|
|
2868
|
+
}
|
|
2869
|
+
function clonePointsByMember() {
|
|
2870
|
+
return workingPointsByMember.map((pts) => pts.map((p) => [...p]));
|
|
2871
|
+
}
|
|
2872
|
+
function restorePointsByMember(snapshot) {
|
|
2873
|
+
for (let i = 0; i < workingPointsByMember.length; i++) workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...snapshot[i]);
|
|
2874
|
+
}
|
|
2875
|
+
function cloneOptionsOverrideByMember() {
|
|
2876
|
+
return optionsOverrideByMember.map((o) => o ? { ...o } : null);
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Pin orientation-derived option defaults (e.g. Battle Position's echelon
|
|
2880
|
+
* anchor) for every member, the moment a group rotate gesture starts —
|
|
2881
|
+
* mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
|
|
2882
|
+
* per-member since the group has no single working-options set.
|
|
2883
|
+
*/
|
|
2884
|
+
function freezeOrientationForRotate() {
|
|
2885
|
+
measures.forEach((measure, i) => {
|
|
2886
|
+
const patch = freezeOrientationOptions(measure.kind, workingPointsByMember[i].map((p) => [...p]), effectiveOptions(i));
|
|
2887
|
+
if (patch) optionsOverrideByMember[i] = {
|
|
2888
|
+
...optionsOverrideByMember[i] ?? {},
|
|
2889
|
+
...patch
|
|
2890
|
+
};
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Emit a completed gesture to every registered listener — the group mirror
|
|
2895
|
+
* of `edit-controller.ts`'s `emitChange` (issue #49 contract): same
|
|
2896
|
+
* instance to all listeners in order, deferred `reject`/`close`/`abort`
|
|
2897
|
+
* (close/abort override reject), exception collection with `AggregateError`.
|
|
2898
|
+
*/
|
|
2899
|
+
function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
|
|
2900
|
+
if (ctx.settled) return;
|
|
2901
|
+
let rejectRequested = false;
|
|
2902
|
+
let closeRequested = false;
|
|
2903
|
+
let abortRequested = false;
|
|
2904
|
+
const event = {
|
|
2905
|
+
measures: currentRendered,
|
|
2906
|
+
previous: previousRendered,
|
|
2907
|
+
session,
|
|
2908
|
+
reject() {
|
|
2909
|
+
rejectRequested = true;
|
|
2910
|
+
},
|
|
2911
|
+
close() {
|
|
2912
|
+
closeRequested = true;
|
|
2913
|
+
},
|
|
2914
|
+
abort() {
|
|
2915
|
+
abortRequested = true;
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
const errors = [];
|
|
2919
|
+
for (const listener of [...changeListeners]) try {
|
|
2920
|
+
listener(event);
|
|
2921
|
+
} catch (err) {
|
|
2922
|
+
errors.push(err);
|
|
2923
|
+
}
|
|
2924
|
+
if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
|
|
2925
|
+
else if (closeRequested) ctx.commit(rebuildRenderedAll);
|
|
2926
|
+
else if (rejectRequested && !ctx.settled) {
|
|
2927
|
+
restorePointsByMember(previousPointsByMember);
|
|
2928
|
+
sizeAnchor = previousSizeAnchor;
|
|
2929
|
+
nonGeometryDirty = previousNonGeometryDirty;
|
|
2930
|
+
optionsOverrideByMember = previousOptionsOverrideByMember.map((o) => o ? { ...o } : null);
|
|
2931
|
+
renderAllPreviews();
|
|
2932
|
+
renderHandlesLayer();
|
|
2933
|
+
recomputeDirty();
|
|
2934
|
+
}
|
|
2935
|
+
if (errors.length === 1) throw errors[0];
|
|
2936
|
+
if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
|
|
2937
|
+
}
|
|
2938
|
+
function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
|
|
2939
|
+
if (ctx.settled) return;
|
|
2940
|
+
const previousPointsByMember = clonePointsByMember();
|
|
2941
|
+
const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
|
|
2942
|
+
const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
|
|
2943
|
+
applyConcatenatedPoints(nextConcatPoints);
|
|
2944
|
+
let currentRendered;
|
|
2945
|
+
try {
|
|
2946
|
+
currentRendered = rebuildRenderedAll();
|
|
2947
|
+
} catch (err) {
|
|
2948
|
+
restorePointsByMember(previousPointsByMember);
|
|
2949
|
+
dragInFlight = false;
|
|
2950
|
+
throw err;
|
|
2951
|
+
}
|
|
2952
|
+
renderAllPreviews();
|
|
2953
|
+
renderHandlesLayer();
|
|
2954
|
+
recomputeDirty();
|
|
2955
|
+
dragInFlight = false;
|
|
2956
|
+
emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
2957
|
+
}
|
|
2958
|
+
function setSizeAnchor(next) {
|
|
2959
|
+
if (ctx.settled || sizeAnchor === next) return;
|
|
2960
|
+
const previousPointsByMember = clonePointsByMember();
|
|
2961
|
+
const previousSizeAnchor = sizeAnchor;
|
|
2962
|
+
const previousNonGeometryDirty = nonGeometryDirty;
|
|
2963
|
+
const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
2964
|
+
const previousRendered = rebuildRenderedAll();
|
|
2965
|
+
sizeAnchor = next;
|
|
2966
|
+
const currentRendered = rebuildRenderedAll();
|
|
2967
|
+
nonGeometryDirty = true;
|
|
2968
|
+
dirty = true;
|
|
2969
|
+
emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
|
|
2970
|
+
}
|
|
2971
|
+
const session = {
|
|
2972
|
+
measures,
|
|
2973
|
+
controlPointsFor(measureId) {
|
|
2974
|
+
const index = measures.findIndex((m) => m.id === measureId);
|
|
2975
|
+
if (index < 0) return void 0;
|
|
2976
|
+
return workingPointsByMember[index].map((p) => [...p]);
|
|
2977
|
+
},
|
|
2978
|
+
get dirty() {
|
|
2979
|
+
return dirty;
|
|
2980
|
+
},
|
|
2981
|
+
get sizeAnchor() {
|
|
2982
|
+
return sizeAnchor;
|
|
2983
|
+
},
|
|
2984
|
+
setSizeAnchor(next) {
|
|
2985
|
+
setSizeAnchor(next);
|
|
2986
|
+
},
|
|
2987
|
+
close() {
|
|
2988
|
+
if (ctx.settled) return;
|
|
2989
|
+
ctx.commit(rebuildRenderedAll);
|
|
2990
|
+
},
|
|
2991
|
+
abort() {
|
|
2992
|
+
if (ctx.settled) return;
|
|
2993
|
+
ctx.fail(new TacticalDrawAbortError("session"));
|
|
2994
|
+
},
|
|
2995
|
+
onChange(listener) {
|
|
2996
|
+
if (ctx.settled) return () => {};
|
|
2997
|
+
changeListeners.add(listener);
|
|
2998
|
+
return () => {
|
|
2999
|
+
changeListeners.delete(listener);
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
};
|
|
3003
|
+
let activeBoxDrag = null;
|
|
3004
|
+
const onDriverDown = (pixel, info) => {
|
|
3005
|
+
if (ctx.settled || dragInFlight) return;
|
|
3006
|
+
if (!groupBox) return;
|
|
3007
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
3008
|
+
const boxHit = pickBoxHandle(groupBox, pixel, tol);
|
|
3009
|
+
if (!boxHit) return;
|
|
3010
|
+
const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
|
|
3011
|
+
if (boxHit.kind === "rotate") {
|
|
3012
|
+
freezeOrientationForRotate();
|
|
3013
|
+
renderAllPreviews();
|
|
3014
|
+
renderHandlesLayer();
|
|
3015
|
+
}
|
|
3016
|
+
const preDragPoints = concatWorkingPoints();
|
|
3017
|
+
activeBoxDrag = {
|
|
3018
|
+
fsm: beginBoxDrag({
|
|
3019
|
+
kind: boxHit.kind,
|
|
3020
|
+
downPixel: pixel,
|
|
3021
|
+
points: preDragPoints,
|
|
3022
|
+
projection: dragProjection,
|
|
3023
|
+
box: groupBox,
|
|
3024
|
+
cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
|
|
3025
|
+
}),
|
|
3026
|
+
preDragPoints,
|
|
3027
|
+
preDragBox: groupBox,
|
|
3028
|
+
downPixel: pixel,
|
|
3029
|
+
lastSnapshot: {
|
|
3030
|
+
points: preDragPoints,
|
|
3031
|
+
box: groupBox
|
|
3032
|
+
},
|
|
3033
|
+
preDragOptionsOverrideByMember
|
|
3034
|
+
};
|
|
3035
|
+
dragInFlight = true;
|
|
3036
|
+
driver.setDragPanEnabled(false);
|
|
3037
|
+
};
|
|
3038
|
+
function paintBoxDragSnapshot(pixel) {
|
|
3039
|
+
if (!activeBoxDrag) return;
|
|
3040
|
+
const snapshot = activeBoxDrag.fsm.move(pixel);
|
|
3041
|
+
activeBoxDrag.lastSnapshot = snapshot;
|
|
3042
|
+
applyConcatenatedPoints(snapshot.points);
|
|
3043
|
+
renderAllPreviews();
|
|
3044
|
+
renderHandlesLayer();
|
|
3045
|
+
recomputeDirty();
|
|
3046
|
+
}
|
|
3047
|
+
const onDriverMove = (pixel) => {
|
|
3048
|
+
if (ctx.settled) return;
|
|
3049
|
+
if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
3050
|
+
};
|
|
3051
|
+
const onDriverUp = (pixel) => {
|
|
3052
|
+
if (!activeBoxDrag) return;
|
|
3053
|
+
if (ctx.settled) {
|
|
3054
|
+
activeBoxDrag = null;
|
|
3055
|
+
dragInFlight = false;
|
|
3056
|
+
driver.setDragPanEnabled(true);
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
|
|
3060
|
+
activeBoxDrag = null;
|
|
3061
|
+
driver.setDragPanEnabled(true);
|
|
3062
|
+
const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
|
|
3063
|
+
if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
|
|
3064
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3065
|
+
renderAllPreviews();
|
|
3066
|
+
renderHandlesLayer();
|
|
3067
|
+
recomputeDirty();
|
|
3068
|
+
dragInFlight = false;
|
|
3069
|
+
suppressClickAway = true;
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
const finalSnapshot = fsm.move(pixel);
|
|
3073
|
+
applyConcatenatedPoints(preDragPoints);
|
|
3074
|
+
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
3075
|
+
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
|
|
3076
|
+
};
|
|
3077
|
+
let pickPreview;
|
|
3078
|
+
let pickHandles;
|
|
3079
|
+
let pickGraphics;
|
|
3080
|
+
const handlePickPreview = (e) => {
|
|
3081
|
+
pickPreview = e;
|
|
3082
|
+
};
|
|
3083
|
+
const handlePickHandles = (e) => {
|
|
3084
|
+
pickHandles = e;
|
|
3085
|
+
};
|
|
3086
|
+
const handlePickGraphics = (e) => {
|
|
3087
|
+
pickGraphics = e;
|
|
3088
|
+
};
|
|
3089
|
+
const handleClick = () => {
|
|
3090
|
+
const localPreview = pickPreview;
|
|
3091
|
+
const localHandles = pickHandles;
|
|
3092
|
+
const localGraphics = pickGraphics;
|
|
3093
|
+
pickPreview = void 0;
|
|
3094
|
+
pickHandles = void 0;
|
|
3095
|
+
pickGraphics = void 0;
|
|
3096
|
+
if (ctx.settled) return;
|
|
3097
|
+
if (!closeOnClickAway) return;
|
|
3098
|
+
if (suppressClickAway) {
|
|
3099
|
+
suppressClickAway = false;
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
if (localPreview || localHandles) return;
|
|
3103
|
+
if (localGraphics && !memberIds.has(localGraphics.id)) {
|
|
3104
|
+
ctx.commit(rebuildRenderedAll);
|
|
3105
|
+
if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
ctx.commit(rebuildRenderedAll);
|
|
3109
|
+
};
|
|
3110
|
+
const core = runInteraction({
|
|
3111
|
+
adapter,
|
|
3112
|
+
signal,
|
|
3113
|
+
layers: [previewLayer, handlesLayer],
|
|
3114
|
+
onViewChange: rescaleOnViewChange,
|
|
3115
|
+
onSettled,
|
|
3116
|
+
attach(c) {
|
|
3117
|
+
ctx = c;
|
|
3118
|
+
driver = adapter.createEditPointerDriver();
|
|
3119
|
+
liftOriginalsFromGraphics();
|
|
3120
|
+
const unsubDown = driver.onPointerDown(onDriverDown);
|
|
3121
|
+
const unsubMove = driver.onPointerMove(onDriverMove);
|
|
3122
|
+
const unsubUp = driver.onPointerUp(onDriverUp);
|
|
3123
|
+
const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
|
|
3124
|
+
const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
|
|
3125
|
+
const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
|
|
3126
|
+
adapter.on("click", handleClick);
|
|
3127
|
+
const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
|
|
3128
|
+
c.onTeardown(() => {
|
|
3129
|
+
adapter.off("click", handleClick);
|
|
3130
|
+
unsubPickPreview();
|
|
3131
|
+
unsubPickHandles();
|
|
3132
|
+
unsubPickGraphics();
|
|
3133
|
+
unsubDown();
|
|
3134
|
+
unsubMove();
|
|
3135
|
+
unsubUp();
|
|
3136
|
+
if (activeBoxDrag !== null) {
|
|
3137
|
+
driver.setDragPanEnabled(true);
|
|
3138
|
+
activeBoxDrag = null;
|
|
3139
|
+
dragInFlight = false;
|
|
3140
|
+
}
|
|
3141
|
+
driver.dispose();
|
|
3142
|
+
}, anyWasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
|
|
3143
|
+
renderAllPreviews();
|
|
3144
|
+
renderHandlesLayer();
|
|
3145
|
+
recomputeDirty();
|
|
3146
|
+
if (onSession) onSession(session);
|
|
3147
|
+
}
|
|
3148
|
+
});
|
|
3149
|
+
return {
|
|
3150
|
+
promise: core.promise,
|
|
3151
|
+
get settled() {
|
|
3152
|
+
return core.settled;
|
|
3153
|
+
},
|
|
3154
|
+
measureIds: measures.map((m) => m.id),
|
|
3155
|
+
abort(reason, cause) {
|
|
3156
|
+
core.abort(reason, cause);
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
//#endregion
|
|
2277
3161
|
//#region src/tactical-draw/errors.ts
|
|
2278
3162
|
/**
|
|
2279
3163
|
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
@@ -2316,6 +3200,20 @@ var TacticalDraw = class {
|
|
|
2316
3200
|
*/
|
|
2317
3201
|
activeEdit = null;
|
|
2318
3202
|
/**
|
|
3203
|
+
* Tracked alongside `activeInteraction` whenever the interaction is a group
|
|
3204
|
+
* transform (`editMany`). The `render()` / view-change paths need every
|
|
3205
|
+
* member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
|
|
3206
|
+
* but for `TransformHandle`'s `measureIds` array.
|
|
3207
|
+
*/
|
|
3208
|
+
activeTransform = null;
|
|
3209
|
+
/**
|
|
3210
|
+
* The live `TransformSession`, exposed via `activeTransformSession`. Set
|
|
3211
|
+
* directly by `editMany`'s `onSession` wrapper (not routed through
|
|
3212
|
+
* `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
|
|
3213
|
+
* and cleared on settle.
|
|
3214
|
+
*/
|
|
3215
|
+
activeTransformSessionRef = null;
|
|
3216
|
+
/**
|
|
2319
3217
|
* Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
|
|
2320
3218
|
* façade subscribes once to `adapter.onPick` per layer and routes events
|
|
2321
3219
|
* through this map so that close-then-pick ordering during an active edit can
|
|
@@ -2333,6 +3231,7 @@ var TacticalDraw = class {
|
|
|
2333
3231
|
deferredGraphicsPick = null;
|
|
2334
3232
|
idCounter = 0;
|
|
2335
3233
|
editCounter = 0;
|
|
3234
|
+
transformCounter = 0;
|
|
2336
3235
|
/**
|
|
2337
3236
|
* Snapshot of the host's last `render()` argument. The edit slice uses it
|
|
2338
3237
|
* to recompute the graphics-layer state during a lift / restore — the
|
|
@@ -2384,12 +3283,24 @@ var TacticalDraw = class {
|
|
|
2384
3283
|
*/
|
|
2385
3284
|
handleViewChange = () => {
|
|
2386
3285
|
if (this.destroyed || !this.hasRendered) return;
|
|
2387
|
-
const
|
|
2388
|
-
const visible =
|
|
3286
|
+
const excluded = this.excludedInteractionIds();
|
|
3287
|
+
const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
|
|
2389
3288
|
if (!visible.some(measureUsesAdapterResolution)) return;
|
|
2390
3289
|
this.renderMeasuresToGraphics(visible);
|
|
2391
3290
|
};
|
|
2392
3291
|
/**
|
|
3292
|
+
* Every measure id currently lifted onto the preview layer by an active
|
|
3293
|
+
* edit or group transform — excluded from `render()` / view-change
|
|
3294
|
+
* reconciliation because the preview layer (not graphics) owns their
|
|
3295
|
+
* working copy. Empty while idle.
|
|
3296
|
+
*/
|
|
3297
|
+
excludedInteractionIds() {
|
|
3298
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3299
|
+
if (this.activeEdit) ids.add(this.activeEdit.measureId);
|
|
3300
|
+
if (this.activeTransform) for (const id of this.activeTransform.measureIds) ids.add(id);
|
|
3301
|
+
return ids;
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
2393
3304
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
2394
3305
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
2395
3306
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -2428,7 +3339,10 @@ var TacticalDraw = class {
|
|
|
2428
3339
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
2429
3340
|
editedRemoved = externalEdited === void 0;
|
|
2430
3341
|
}
|
|
2431
|
-
const
|
|
3342
|
+
const transformIds = this.activeTransform?.measureIds;
|
|
3343
|
+
const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
|
|
3344
|
+
const excluded = this.excludedInteractionIds();
|
|
3345
|
+
const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
|
|
2432
3346
|
if (!(visible.length === 0 && !this.hasRendered)) {
|
|
2433
3347
|
this.renderMeasuresToGraphics(visible);
|
|
2434
3348
|
this.hasRendered = true;
|
|
@@ -2441,6 +3355,12 @@ var TacticalDraw = class {
|
|
|
2441
3355
|
editHandle.abort("removed");
|
|
2442
3356
|
});
|
|
2443
3357
|
}
|
|
3358
|
+
if (transformMemberRemoved && this.activeTransform) {
|
|
3359
|
+
const transformHandle = this.activeTransform;
|
|
3360
|
+
queueMicrotask(() => {
|
|
3361
|
+
transformHandle.abort("removed");
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
2444
3364
|
}
|
|
2445
3365
|
/**
|
|
2446
3366
|
* Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
|
|
@@ -2458,6 +3378,8 @@ var TacticalDraw = class {
|
|
|
2458
3378
|
}
|
|
2459
3379
|
if (this.activeInteraction.isActive) {
|
|
2460
3380
|
this.activeEdit = null;
|
|
3381
|
+
this.activeTransform = null;
|
|
3382
|
+
this.activeTransformSessionRef = null;
|
|
2461
3383
|
this.activeInteraction.cancel("preempted");
|
|
2462
3384
|
}
|
|
2463
3385
|
return null;
|
|
@@ -2473,14 +3395,17 @@ var TacticalDraw = class {
|
|
|
2473
3395
|
* whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
|
|
2474
3396
|
* caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
|
|
2475
3397
|
*/
|
|
2476
|
-
runWithSlot(create,
|
|
3398
|
+
runWithSlot(create, activate, extraSettle) {
|
|
2477
3399
|
let handle;
|
|
2478
|
-
let
|
|
3400
|
+
let deactivate = null;
|
|
2479
3401
|
let settledDuringCreate = false;
|
|
2480
3402
|
const clearSlot = () => {
|
|
2481
3403
|
if (handle) {
|
|
2482
3404
|
this.activeInteraction.clear(handle);
|
|
2483
|
-
if (
|
|
3405
|
+
if (deactivate) {
|
|
3406
|
+
deactivate();
|
|
3407
|
+
deactivate = null;
|
|
3408
|
+
}
|
|
2484
3409
|
} else {
|
|
2485
3410
|
settledDuringCreate = true;
|
|
2486
3411
|
this.activeInteraction.clear();
|
|
@@ -2493,7 +3418,6 @@ var TacticalDraw = class {
|
|
|
2493
3418
|
this.activeInteraction.begin();
|
|
2494
3419
|
try {
|
|
2495
3420
|
handle = create(onSettled);
|
|
2496
|
-
editHandle = editHandleOf ? editHandleOf(handle) : null;
|
|
2497
3421
|
} catch (error) {
|
|
2498
3422
|
this.activeInteraction.clear();
|
|
2499
3423
|
if (!settledDuringCreate && extraSettle) extraSettle();
|
|
@@ -2501,7 +3425,7 @@ var TacticalDraw = class {
|
|
|
2501
3425
|
}
|
|
2502
3426
|
if (!settledDuringCreate) {
|
|
2503
3427
|
this.activeInteraction.set(handle);
|
|
2504
|
-
if (
|
|
3428
|
+
if (activate && !handle.settled) deactivate = activate(handle) ?? null;
|
|
2505
3429
|
}
|
|
2506
3430
|
handle.promise.then(clearSlot, clearSlot);
|
|
2507
3431
|
return handle.promise;
|
|
@@ -2517,6 +3441,18 @@ var TacticalDraw = class {
|
|
|
2517
3441
|
};
|
|
2518
3442
|
}
|
|
2519
3443
|
/**
|
|
3444
|
+
* Wrap a host `onSession` callback for `editMany`, recording the session on
|
|
3445
|
+
* `activeTransformSessionRef` (exposed via `activeTransformSession`) before
|
|
3446
|
+
* the host's own handler runs. Separate from `trackSession` because
|
|
3447
|
+
* `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
|
|
3448
|
+
*/
|
|
3449
|
+
trackTransformSession(hostOnSession) {
|
|
3450
|
+
return (session) => {
|
|
3451
|
+
this.activeTransformSessionRef = session;
|
|
3452
|
+
hostOnSession?.(session);
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
/**
|
|
2520
3456
|
* Start an interactive draw. Fixed-length kinds commit automatically on the
|
|
2521
3457
|
* last required click. Variable-length kinds surface a `DrawSession` via
|
|
2522
3458
|
* `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
|
|
@@ -2543,6 +3479,7 @@ var TacticalDraw = class {
|
|
|
2543
3479
|
adapter: this.adapter,
|
|
2544
3480
|
kind,
|
|
2545
3481
|
previewLayer: this.previewLayer,
|
|
3482
|
+
guideLayer: this.guideLayer,
|
|
2546
3483
|
graphicsStyle: this.graphicsStyle,
|
|
2547
3484
|
generateId: () => draft.id ?? this.generateId(),
|
|
2548
3485
|
signal: options?.signal,
|
|
@@ -2582,9 +3519,7 @@ var TacticalDraw = class {
|
|
|
2582
3519
|
*/
|
|
2583
3520
|
edit(measure, options) {
|
|
2584
3521
|
this.assertAlive();
|
|
2585
|
-
|
|
2586
|
-
for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2587
|
-
}
|
|
3522
|
+
normaliseModes(options?.modes);
|
|
2588
3523
|
const preempt = this.guardAndPreempt();
|
|
2589
3524
|
if (preempt) return preempt;
|
|
2590
3525
|
const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
|
|
@@ -2617,7 +3552,71 @@ var TacticalDraw = class {
|
|
|
2617
3552
|
wasOnGraphicsLayer,
|
|
2618
3553
|
onSettled,
|
|
2619
3554
|
onClickAwayGraphicsPick
|
|
2620
|
-
}), (handle) =>
|
|
3555
|
+
}), (handle) => {
|
|
3556
|
+
this.activeEdit = handle;
|
|
3557
|
+
return () => {
|
|
3558
|
+
if (this.activeEdit === handle) this.activeEdit = null;
|
|
3559
|
+
};
|
|
3560
|
+
}, revertGuideStyle);
|
|
3561
|
+
}
|
|
3562
|
+
/**
|
|
3563
|
+
* Start an interactive group transform (multi-select) over `measures`
|
|
3564
|
+
* (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
|
|
3565
|
+
* preview layer; the box (corner scale, rotate grip, drag-anywhere body
|
|
3566
|
+
* translate) applies rigidly to all of them at once. Returns a Promise that
|
|
3567
|
+
* resolves with index-aligned `ControlMeasureSnapshot[]` on
|
|
3568
|
+
* `session.close()` and rejects with `TacticalDrawAbortError` on
|
|
3569
|
+
* signal / Escape / destroy / preempt / `session.abort()`.
|
|
3570
|
+
*
|
|
3571
|
+
* `measures.length` must be >= 1; a single-element array behaves like an
|
|
3572
|
+
* `edit` with only `transform` mode. Throws `TypeError` synchronously (before
|
|
3573
|
+
* any preemption) for an empty array or duplicate ids.
|
|
3574
|
+
*/
|
|
3575
|
+
editMany(measures, options) {
|
|
3576
|
+
this.assertAlive();
|
|
3577
|
+
if (measures.length === 0) throw new TypeError("td.editMany: measures must not be empty");
|
|
3578
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3579
|
+
for (const m of measures) {
|
|
3580
|
+
if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate control measure id "${m.id}"`);
|
|
3581
|
+
seenIds.add(m.id);
|
|
3582
|
+
}
|
|
3583
|
+
const preempt = this.guardAndPreempt();
|
|
3584
|
+
if (preempt) return preempt;
|
|
3585
|
+
const wasOnGraphicsLayer = measures.map((m) => this.lastRenderedMeasures.some((rendered) => rendered.id === m.id));
|
|
3586
|
+
const restoreOriginalToGraphics = () => {
|
|
3587
|
+
this.renderMeasuresToGraphics(this.lastRenderedMeasures);
|
|
3588
|
+
};
|
|
3589
|
+
const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
|
|
3590
|
+
const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
|
|
3591
|
+
const onClickAwayGraphicsPick = (event) => {
|
|
3592
|
+
this.dispatchHostPick(this.graphicsLayer, event);
|
|
3593
|
+
};
|
|
3594
|
+
const extraSettle = () => {
|
|
3595
|
+
this.activeTransformSessionRef = null;
|
|
3596
|
+
};
|
|
3597
|
+
return this.runWithSlot((onSettled) => runGroupTransform({
|
|
3598
|
+
adapter: this.adapter,
|
|
3599
|
+
measures,
|
|
3600
|
+
graphicsLayer: this.graphicsLayer,
|
|
3601
|
+
previewLayer: this.previewLayer,
|
|
3602
|
+
handlesLayer: this.handlesLayer,
|
|
3603
|
+
graphicsStyle: this.graphicsStyle,
|
|
3604
|
+
signal: options?.signal,
|
|
3605
|
+
closeOnClickAway: options?.closeOnClickAway,
|
|
3606
|
+
interactionStyle: mergedInteractionStyle,
|
|
3607
|
+
onSession: this.trackTransformSession(options?.onSession),
|
|
3608
|
+
handleIdPrefix,
|
|
3609
|
+
sizeAnchor: options?.sizeAnchor,
|
|
3610
|
+
restoreOriginalToGraphics,
|
|
3611
|
+
wasOnGraphicsLayer,
|
|
3612
|
+
onSettled,
|
|
3613
|
+
onClickAwayGraphicsPick
|
|
3614
|
+
}), (handle) => {
|
|
3615
|
+
this.activeTransform = handle;
|
|
3616
|
+
return () => {
|
|
3617
|
+
if (this.activeTransform === handle) this.activeTransform = null;
|
|
3618
|
+
};
|
|
3619
|
+
}, extraSettle);
|
|
2621
3620
|
}
|
|
2622
3621
|
/**
|
|
2623
3622
|
* Subscribe to picks on committed control measures.
|
|
@@ -2685,6 +3684,15 @@ var TacticalDraw = class {
|
|
|
2685
3684
|
return this.activeInteraction.session;
|
|
2686
3685
|
}
|
|
2687
3686
|
/**
|
|
3687
|
+
* The live `editMany` group-transform session, or `null` while idle (and
|
|
3688
|
+
* during any other interaction). Separate from `activeSession` because
|
|
3689
|
+
* `TransformSession` isn't part of its `DrawSession | EditSession` union —
|
|
3690
|
+
* see `activeTransformSessionRef`. Safe to read after `destroy()`.
|
|
3691
|
+
*/
|
|
3692
|
+
get activeTransformSession() {
|
|
3693
|
+
return this.activeTransformSessionRef;
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
2688
3696
|
* Tear down the façade. Releases only layer slots the façade created; host
|
|
2689
3697
|
* supplied layer ids are left untouched. Safe to call multiple times. After
|
|
2690
3698
|
* the first call every public method throws `TacticalDrawDestroyedError`.
|
|
@@ -2694,6 +3702,8 @@ var TacticalDraw = class {
|
|
|
2694
3702
|
this.destroyed = true;
|
|
2695
3703
|
if (this.activeInteraction.isActive) {
|
|
2696
3704
|
this.activeEdit = null;
|
|
3705
|
+
this.activeTransform = null;
|
|
3706
|
+
this.activeTransformSessionRef = null;
|
|
2697
3707
|
this.activeInteraction.cancel("destroyed");
|
|
2698
3708
|
} else this.activeInteraction.clear();
|
|
2699
3709
|
this.adapter.offViewChange(this.handleViewChange);
|
|
@@ -2713,7 +3723,8 @@ var TacticalDraw = class {
|
|
|
2713
3723
|
* from a host pick handler observes a settled session.
|
|
2714
3724
|
*/
|
|
2715
3725
|
routePick(layerId, event) {
|
|
2716
|
-
|
|
3726
|
+
const excluded = this.excludedInteractionIds();
|
|
3727
|
+
if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
|
|
2717
3728
|
this.deferredGraphicsPick = event;
|
|
2718
3729
|
return;
|
|
2719
3730
|
}
|
|
@@ -2752,9 +3763,19 @@ var TacticalDraw = class {
|
|
|
2752
3763
|
return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
|
|
2753
3764
|
}
|
|
2754
3765
|
/**
|
|
2755
|
-
* Resolve handle slots across built-in → façade →
|
|
2756
|
-
* per-property shallow merge. Explicit `undefined`
|
|
2757
|
-
* layer unsets the corresponding lower-priority field
|
|
3766
|
+
* Resolve handle slots (plus `boxOutline`) across built-in → façade →
|
|
3767
|
+
* per-call with per-slot, per-property shallow merge. Explicit `undefined`
|
|
3768
|
+
* at a higher-priority layer unsets the corresponding lower-priority field
|
|
3769
|
+
* (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
|
|
3770
|
+
* `guide` layer-style path) — the edit controller reads it off
|
|
3771
|
+
* `properties.style` on the box outline `LineString` (ADR-0017).
|
|
3772
|
+
*
|
|
3773
|
+
* `boxPadding` is a scalar, not a mergeable style object, so it resolves
|
|
3774
|
+
* separately with the same façade → per-call precedence and the same
|
|
3775
|
+
* explicit-undefined-wins rule: a `boxPadding` key present at a
|
|
3776
|
+
* higher-priority layer (even set to `undefined`) wins over a lower layer's
|
|
3777
|
+
* value. There is no built-in `boxPadding` — an absent value at every layer
|
|
3778
|
+
* falls through to the controllers' own `?? 0` default.
|
|
2758
3779
|
*
|
|
2759
3780
|
* Returns `undefined` only when no slot would carry any value — keeps the
|
|
2760
3781
|
* downstream "no style on the feature" path intact for tests that assert
|
|
@@ -2766,6 +3787,10 @@ var TacticalDraw = class {
|
|
|
2766
3787
|
const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
|
|
2767
3788
|
if (merged) out[slot] = merged;
|
|
2768
3789
|
}
|
|
3790
|
+
const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
|
|
3791
|
+
if (boxOutline) out.boxOutline = boxOutline;
|
|
3792
|
+
const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
|
|
3793
|
+
if (typeof boxPadding === "number") out.boxPadding = boxPadding;
|
|
2769
3794
|
return out;
|
|
2770
3795
|
}
|
|
2771
3796
|
assertAlive() {
|
|
@@ -2782,10 +3807,12 @@ const BUILT_IN_GRAPHICS_STYLE = {
|
|
|
2782
3807
|
strokeWidth: 2
|
|
2783
3808
|
};
|
|
2784
3809
|
/**
|
|
2785
|
-
* Built-in defaults for the interaction-affordance slots
|
|
2786
|
-
*
|
|
2787
|
-
*
|
|
2788
|
-
*
|
|
3810
|
+
* Built-in defaults for the interaction-affordance slots (ADR-0006, extended
|
|
3811
|
+
* for the ADR-0017 transform box). The scale (corner) grips are white-filled
|
|
3812
|
+
* with the same blue stroke as the box outline, so the box reads as one
|
|
3813
|
+
* coherent affordance; the rotate grip keeps its distinct green fill so it
|
|
3814
|
+
* doesn't get mistaken for a corner. Vertex/midpoint keep their existing
|
|
3815
|
+
* white/gray family.
|
|
2789
3816
|
*/
|
|
2790
3817
|
const BUILT_IN_INTERACTION_STYLE = {
|
|
2791
3818
|
guide: {
|
|
@@ -2793,6 +3820,10 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2793
3820
|
strokeWidth: 1,
|
|
2794
3821
|
strokeDash: [6, 4]
|
|
2795
3822
|
},
|
|
3823
|
+
boxOutline: {
|
|
3824
|
+
strokeColor: "#4f7cff",
|
|
3825
|
+
strokeWidth: 1
|
|
3826
|
+
},
|
|
2796
3827
|
vertexHandle: {
|
|
2797
3828
|
pointRadius: 6,
|
|
2798
3829
|
fillColor: "#ffffff",
|
|
@@ -2805,13 +3836,14 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2805
3836
|
strokeColor: "#6b7280",
|
|
2806
3837
|
strokeWidth: 1.5
|
|
2807
3838
|
},
|
|
2808
|
-
|
|
2809
|
-
pointRadius:
|
|
2810
|
-
fillColor: "#
|
|
2811
|
-
strokeColor: "#
|
|
3839
|
+
scaleHandle: {
|
|
3840
|
+
pointRadius: 5,
|
|
3841
|
+
fillColor: "#ffffff",
|
|
3842
|
+
strokeColor: "#4f7cff",
|
|
3843
|
+
strokeWidth: 2
|
|
2812
3844
|
},
|
|
2813
3845
|
rotateHandle: {
|
|
2814
|
-
pointRadius:
|
|
3846
|
+
pointRadius: 5,
|
|
2815
3847
|
fillColor: "#16a34a",
|
|
2816
3848
|
strokeColor: "#ffffff"
|
|
2817
3849
|
}
|
|
@@ -2819,7 +3851,7 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2819
3851
|
const HANDLE_SLOTS = [
|
|
2820
3852
|
"vertexHandle",
|
|
2821
3853
|
"midpointHandle",
|
|
2822
|
-
"
|
|
3854
|
+
"scaleHandle",
|
|
2823
3855
|
"rotateHandle"
|
|
2824
3856
|
];
|
|
2825
3857
|
/**
|