@orbat-mapper/tactical-draw 0.2.0-alpha.4 → 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 +1359 -335
- 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";
|
|
@@ -1251,63 +1260,14 @@ function outlinePreview(points) {
|
|
|
1251
1260
|
//#endregion
|
|
1252
1261
|
//#region src/tactical-draw/handles.ts
|
|
1253
1262
|
/**
|
|
1254
|
-
* Compute the anchor used by translate and rotate handles. The anchor is the
|
|
1255
|
-
* centroid (arithmetic mean) of the control-point list — stable, kind
|
|
1256
|
-
* agnostic, and well-defined for any non-empty point set.
|
|
1257
|
-
*/
|
|
1258
|
-
function computeHandleAnchor(controlPoints) {
|
|
1259
|
-
if (controlPoints.length === 0) return null;
|
|
1260
|
-
let sx = 0;
|
|
1261
|
-
let sy = 0;
|
|
1262
|
-
for (const p of controlPoints) {
|
|
1263
|
-
sx += p[0];
|
|
1264
|
-
sy += p[1];
|
|
1265
|
-
}
|
|
1266
|
-
return [sx / controlPoints.length, sy / controlPoints.length];
|
|
1267
|
-
}
|
|
1268
|
-
/**
|
|
1269
|
-
* How far past the outermost control point the rotate grip sits. `1.3` places
|
|
1270
|
-
* it 30% beyond the farthest vertex, clearing the geometry while staying
|
|
1271
|
-
* proportional to the shape's size.
|
|
1272
|
-
*/
|
|
1273
|
-
const ROTATE_HANDLE_REACH_FACTOR = 1.3;
|
|
1274
|
-
/**
|
|
1275
|
-
* Position of the rotate grip. Unlike the translate handle — which sits on the
|
|
1276
|
-
* centroid pivot — the rotate handle needs a lever arm, so it is offset from
|
|
1277
|
-
* the centroid along the ray toward the control point farthest from it, just
|
|
1278
|
-
* outside the shape. Anchoring to a *material* point (rather than the
|
|
1279
|
-
* axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
|
|
1280
|
-
* grip orbits with the shape as it rotates, since the farthest vertex rotates
|
|
1281
|
-
* with it. Falls back to the centroid when every point coincides (no
|
|
1282
|
-
* well-defined direction) or the list is empty.
|
|
1283
|
-
*/
|
|
1284
|
-
function computeRotateHandlePosition(controlPoints) {
|
|
1285
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1286
|
-
if (!anchor) return null;
|
|
1287
|
-
let farthest = null;
|
|
1288
|
-
let maxDistSq = 0;
|
|
1289
|
-
for (const p of controlPoints) {
|
|
1290
|
-
const dx = p[0] - anchor[0];
|
|
1291
|
-
const dy = p[1] - anchor[1];
|
|
1292
|
-
const distSq = dx * dx + dy * dy;
|
|
1293
|
-
if (distSq > maxDistSq) {
|
|
1294
|
-
maxDistSq = distSq;
|
|
1295
|
-
farthest = p;
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
if (!farthest) return anchor;
|
|
1299
|
-
return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
|
|
1300
|
-
}
|
|
1301
|
-
/**
|
|
1302
1263
|
* Axis-aligned bounding-box center of the control points — the midpoint of
|
|
1303
|
-
* `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
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.
|
|
1307
1268
|
*
|
|
1308
|
-
* Exported because the rotation pivot is *not* a handle
|
|
1309
|
-
*
|
|
1310
|
-
* 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.
|
|
1311
1271
|
*/
|
|
1312
1272
|
function computeBoundingBoxCenter(controlPoints) {
|
|
1313
1273
|
if (controlPoints.length === 0) return null;
|
|
@@ -1333,23 +1293,23 @@ function segmentMidpoint(a, b) {
|
|
|
1333
1293
|
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1334
1294
|
}
|
|
1335
1295
|
/**
|
|
1336
|
-
* The single source of truth for
|
|
1337
|
-
* `EditMode → which handles` gating (ADR-0016). Pure and
|
|
1338
|
-
* positions are lonLat. Handles are emitted in **pick
|
|
1339
|
-
* `[vertices…, midpoints
|
|
1340
|
-
* 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
|
|
1341
1301
|
* midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
|
|
1342
1302
|
* projections of this set; the drag controller seeds gestures from it too.
|
|
1343
1303
|
*
|
|
1344
1304
|
* Emits handles only for modes present in `modes`:
|
|
1345
1305
|
* - `reshape`: vertex handle at every control point + midpoint handle on
|
|
1346
1306
|
* every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
|
|
1347
|
-
* - `delete`: vertex handles only (tapped to remove), no
|
|
1348
|
-
* - `translate`: a single translate handle at the centroid anchor;
|
|
1349
|
-
* - `rotate`: a single rotate grip offset outside the shape.
|
|
1307
|
+
* - `delete`: vertex handles only (tapped to remove), no midpoints.
|
|
1350
1308
|
*
|
|
1351
|
-
* `
|
|
1352
|
-
* 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.
|
|
1353
1313
|
*/
|
|
1354
1314
|
function buildHandleSet(controlPoints, modes, options = {}) {
|
|
1355
1315
|
const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
|
|
@@ -1373,29 +1333,13 @@ function buildHandleSet(controlPoints, modes, options = {}) {
|
|
|
1373
1333
|
}
|
|
1374
1334
|
}
|
|
1375
1335
|
}
|
|
1376
|
-
if (modes.includes("translate")) {
|
|
1377
|
-
const anchor = computeHandleAnchor(controlPoints);
|
|
1378
|
-
if (anchor) handles.push({
|
|
1379
|
-
kind: "translate",
|
|
1380
|
-
index: 0,
|
|
1381
|
-
position: anchor
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
if (modes.includes("rotate")) {
|
|
1385
|
-
const rotatePos = computeRotateHandlePosition(controlPoints);
|
|
1386
|
-
if (rotatePos) handles.push({
|
|
1387
|
-
kind: "rotate",
|
|
1388
|
-
index: 0,
|
|
1389
|
-
position: rotatePos
|
|
1390
|
-
});
|
|
1391
|
-
}
|
|
1392
1336
|
return handles;
|
|
1393
1337
|
}
|
|
1394
1338
|
function handleFeatureId(prefix, kind, index) {
|
|
1395
1339
|
switch (kind) {
|
|
1396
1340
|
case "vertex": return `${prefix}:handle-vertex:${index}`;
|
|
1397
1341
|
case "midpoint": return `${prefix}:handle-midpoint:${index}`;
|
|
1398
|
-
case "
|
|
1342
|
+
case "scale": return `${prefix}:handle-scale:${index}`;
|
|
1399
1343
|
case "rotate": return `${prefix}:handle-rotate`;
|
|
1400
1344
|
}
|
|
1401
1345
|
}
|
|
@@ -1403,16 +1347,18 @@ function handleStyleFor(kind, interactionStyle) {
|
|
|
1403
1347
|
switch (kind) {
|
|
1404
1348
|
case "vertex": return interactionStyle?.vertexHandle;
|
|
1405
1349
|
case "midpoint": return interactionStyle?.midpointHandle;
|
|
1406
|
-
case "
|
|
1350
|
+
case "scale": return interactionStyle?.scaleHandle;
|
|
1407
1351
|
case "rotate": return interactionStyle?.rotateHandle;
|
|
1408
1352
|
}
|
|
1409
1353
|
}
|
|
1410
1354
|
/**
|
|
1411
1355
|
* Project a handle set into the GeoJSON features that belong on the handles
|
|
1412
|
-
* layer — the render projection of `buildHandleSet` (ADR-0016)
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
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.
|
|
1416
1362
|
*/
|
|
1417
1363
|
function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
1418
1364
|
return handles.map((handle) => {
|
|
@@ -1435,26 +1381,142 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
|
|
|
1435
1381
|
};
|
|
1436
1382
|
});
|
|
1437
1383
|
}
|
|
1438
|
-
function pixelDistance(a, b) {
|
|
1384
|
+
function pixelDistance$2(a, b) {
|
|
1439
1385
|
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1440
1386
|
}
|
|
1441
1387
|
/**
|
|
1442
1388
|
* The pick projection of `buildHandleSet` (ADR-0016): the first handle within
|
|
1443
1389
|
* `tolerance` pixels of `pixel`, tested in the set's priority order. Because
|
|
1444
|
-
* `buildHandleSet` orders vertices before midpoints
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
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.
|
|
1450
1396
|
*/
|
|
1451
1397
|
function pickHandle(handles, pixel, tolerance, toPixel) {
|
|
1452
1398
|
for (const handle of handles) {
|
|
1453
1399
|
const projected = toPixel(handle.position);
|
|
1454
|
-
if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
|
|
1400
|
+
if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
|
|
1455
1401
|
}
|
|
1456
1402
|
return null;
|
|
1457
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
|
+
}
|
|
1458
1520
|
//#endregion
|
|
1459
1521
|
//#region src/tactical-draw/handle-drag.ts
|
|
1460
1522
|
function clonePoints(points) {
|
|
@@ -1468,17 +1530,8 @@ function translatePoints(base, dx, dy) {
|
|
|
1468
1530
|
return out;
|
|
1469
1531
|
});
|
|
1470
1532
|
}
|
|
1471
|
-
function
|
|
1472
|
-
return Math.
|
|
1473
|
-
}
|
|
1474
|
-
/**
|
|
1475
|
-
* Angle of `pixel` around the rotation pivot, measured in screen space so it
|
|
1476
|
-
* matches what the user sees and does. Falls back to lon/lat when the pivot
|
|
1477
|
-
* cannot be projected — exceedingly rare for an on-screen edit.
|
|
1478
|
-
*/
|
|
1479
|
-
function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
|
|
1480
|
-
if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
|
|
1481
|
-
return angleFromAnchor(pivot, projection.fromPixel(pixel));
|
|
1533
|
+
function pixelDistance(a, b) {
|
|
1534
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
1482
1535
|
}
|
|
1483
1536
|
/**
|
|
1484
1537
|
* Rotate `points` by `angle` about the pivot in screen space — project each
|
|
@@ -1536,7 +1589,7 @@ function begun(kind, initialBase, move) {
|
|
|
1536
1589
|
* caller's working copy.
|
|
1537
1590
|
*/
|
|
1538
1591
|
function beginHandleDrag(args) {
|
|
1539
|
-
const { hit,
|
|
1592
|
+
const { hit, points, projection, rule } = args;
|
|
1540
1593
|
const { fromPixel } = projection;
|
|
1541
1594
|
const preDragPoints = clonePoints(points);
|
|
1542
1595
|
const snap = (next, activePointIndex) => rule ? rule.transform({
|
|
@@ -1546,46 +1599,103 @@ function beginHandleDrag(args) {
|
|
|
1546
1599
|
}) : next;
|
|
1547
1600
|
const dragOnePoint = (base, activeIndex) => {
|
|
1548
1601
|
const move = (pixel) => {
|
|
1549
|
-
return {
|
|
1550
|
-
points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
|
|
1551
|
-
rotateGripOverride: null
|
|
1552
|
-
};
|
|
1602
|
+
return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
|
|
1553
1603
|
};
|
|
1554
1604
|
return move;
|
|
1555
1605
|
};
|
|
1556
1606
|
if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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);
|
|
1572
1638
|
return {
|
|
1573
|
-
points:
|
|
1574
|
-
|
|
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
|
+
}
|
|
1575
1653
|
};
|
|
1576
|
-
}
|
|
1654
|
+
};
|
|
1655
|
+
return {
|
|
1656
|
+
kind,
|
|
1657
|
+
move,
|
|
1658
|
+
end: (pixel) => move(pixel).points
|
|
1659
|
+
};
|
|
1577
1660
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
+
};
|
|
1584
1677
|
return {
|
|
1585
|
-
|
|
1586
|
-
|
|
1678
|
+
kind,
|
|
1679
|
+
move,
|
|
1680
|
+
end: (pixel) => move(pixel).points
|
|
1587
1681
|
};
|
|
1588
|
-
}
|
|
1682
|
+
}
|
|
1683
|
+
const origin = fromPixel(downPixel);
|
|
1684
|
+
const move = (pixel) => {
|
|
1685
|
+
const cur = fromPixel(pixel);
|
|
1686
|
+
return {
|
|
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
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
};
|
|
1694
|
+
return {
|
|
1695
|
+
kind,
|
|
1696
|
+
move,
|
|
1697
|
+
end: (pixel) => move(pixel).points
|
|
1698
|
+
};
|
|
1589
1699
|
}
|
|
1590
1700
|
//#endregion
|
|
1591
1701
|
//#region src/tactical-draw/edit-session.ts
|
|
@@ -1607,8 +1717,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
|
|
|
1607
1717
|
const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
|
|
1608
1718
|
/**
|
|
1609
1719
|
* Internal test seam — simulate a completed rotate gesture by passing an angle
|
|
1610
|
-
* in radians. Every control point rotates around the
|
|
1611
|
-
*
|
|
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).
|
|
1612
1724
|
*
|
|
1613
1725
|
* @internal
|
|
1614
1726
|
*/
|
|
@@ -1629,6 +1741,78 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
|
|
|
1629
1741
|
*/
|
|
1630
1742
|
const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
1631
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
|
|
1632
1816
|
//#region src/tactical-draw/edit-controller.ts
|
|
1633
1817
|
/**
|
|
1634
1818
|
* Coordinate-space tolerance for `EditSession.dirty`. The working copy is
|
|
@@ -1637,27 +1821,32 @@ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
|
|
|
1637
1821
|
* dirty tracking; the constant lives in one place so future slices can lift
|
|
1638
1822
|
* it to an option without touching call sites.
|
|
1639
1823
|
*/
|
|
1640
|
-
const DIRTY_EPSILON = 1e-9;
|
|
1641
|
-
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;
|
|
1642
1835
|
const ALLOWED_EDIT_MODES = new Set([
|
|
1643
1836
|
"reshape",
|
|
1644
|
-
"
|
|
1645
|
-
"rotate",
|
|
1837
|
+
"transform",
|
|
1646
1838
|
"delete"
|
|
1647
1839
|
]);
|
|
1648
1840
|
/**
|
|
1649
|
-
* Drive an interactive edit against a `MapAdapter`. Implements the
|
|
1650
|
-
*
|
|
1841
|
+
* Drive an interactive edit against a `MapAdapter`. Implements the edit
|
|
1842
|
+
* lifecycle (issue #48) plus the ADR-0017 transform box:
|
|
1651
1843
|
*
|
|
1652
1844
|
* - lift-original (remove from graphics layer) + working copy
|
|
1653
|
-
* - preview + handle rendering
|
|
1845
|
+
* - preview + reshape-handle rendering, and the oriented transform box
|
|
1846
|
+
* (corner scale handles, rotate grip, drag-anywhere-inside body translate)
|
|
1654
1847
|
* - dirty tracking via [[DIRTY_EPSILON]] tolerance
|
|
1655
1848
|
* - default click-away close policy
|
|
1656
1849
|
* - close / abort / signal / preempt / destroy paths
|
|
1657
|
-
*
|
|
1658
|
-
* Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
|
|
1659
|
-
* gesture binding lands with the change-event spec in #49 and the
|
|
1660
|
-
* translate/rotate handle set in #50.
|
|
1661
1850
|
*/
|
|
1662
1851
|
function runEdit(opts) {
|
|
1663
1852
|
const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
|
|
@@ -1698,12 +1887,55 @@ function runEdit(opts) {
|
|
|
1698
1887
|
*/
|
|
1699
1888
|
let pendingModes = null;
|
|
1700
1889
|
const changeListeners = /* @__PURE__ */ new Set();
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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 = [];
|
|
1907
|
+
let ctx;
|
|
1908
|
+
let driver;
|
|
1909
|
+
const dragProjection = {
|
|
1704
1910
|
toPixel: (lonLat) => driver.toPixel(lonLat),
|
|
1705
1911
|
fromPixel: (pixel) => driver.fromPixel(pixel)
|
|
1706
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
|
+
}
|
|
1707
1939
|
function liftOriginalFromGraphics() {
|
|
1708
1940
|
if (!wasOnGraphicsLayer) return;
|
|
1709
1941
|
const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
|
|
@@ -1724,38 +1956,48 @@ function runEdit(opts) {
|
|
|
1724
1956
|
features = [];
|
|
1725
1957
|
}
|
|
1726
1958
|
ctx.writeLayer(previewLayer, features);
|
|
1727
|
-
|
|
1959
|
+
lastPreviewFeatures = features;
|
|
1960
|
+
refitBox();
|
|
1961
|
+
const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
|
|
1962
|
+
const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
|
|
1728
1963
|
ctx.writeLayer(guideLayer, guideFeatures);
|
|
1729
1964
|
}
|
|
1730
1965
|
function rescaleOnViewChange() {
|
|
1731
|
-
if (
|
|
1966
|
+
if (ctx.settled) return;
|
|
1967
|
+
if (measureUsesAdapterResolution({
|
|
1732
1968
|
kind: measure.kind,
|
|
1733
1969
|
options: workingOptions
|
|
1734
|
-
}))
|
|
1735
|
-
|
|
1970
|
+
})) renderPreview();
|
|
1971
|
+
else refitBox();
|
|
1972
|
+
renderHandlesLayer();
|
|
1736
1973
|
}
|
|
1737
1974
|
function renderHandlesLayer() {
|
|
1738
1975
|
if (ctx.settled) return;
|
|
1739
|
-
|
|
1976
|
+
const features = toFeatures(buildHandleSet(workingPoints, modes, {
|
|
1740
1977
|
includeMidpoints: midpointHandlesEnabled,
|
|
1741
1978
|
trailingFixedSlots,
|
|
1742
1979
|
closedRing
|
|
1743
|
-
});
|
|
1744
|
-
if (
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
const features = toFeatures(handles, handleIdPrefix, interactionStyle);
|
|
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
|
+
}
|
|
1752
1988
|
ctx.writeLayer(handlesLayer, features);
|
|
1753
1989
|
}
|
|
1754
|
-
|
|
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) {
|
|
1755
1997
|
const working = {
|
|
1756
1998
|
...measure,
|
|
1757
1999
|
style: currentStyle,
|
|
1758
|
-
options
|
|
2000
|
+
options,
|
|
1759
2001
|
controlPoints: workingPoints.map((p) => [...p])
|
|
1760
2002
|
};
|
|
1761
2003
|
const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
|
|
@@ -1764,8 +2006,11 @@ function runEdit(opts) {
|
|
|
1764
2006
|
render: renderMeasureForAdapter(adapter, committed, renderOpts)
|
|
1765
2007
|
};
|
|
1766
2008
|
}
|
|
2009
|
+
function rebuildRendered() {
|
|
2010
|
+
return rebuildRenderedWith(workingOptions);
|
|
2011
|
+
}
|
|
1767
2012
|
function recomputeDirty() {
|
|
1768
|
-
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
|
|
2013
|
+
dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
|
|
1769
2014
|
}
|
|
1770
2015
|
function replaceWorkingPoints(next) {
|
|
1771
2016
|
workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
|
|
@@ -1774,6 +2019,23 @@ function runEdit(opts) {
|
|
|
1774
2019
|
workingOptions = { ...next ?? {} };
|
|
1775
2020
|
}
|
|
1776
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
|
+
/**
|
|
1777
2039
|
* Emit a completed gesture to every registered listener. The pre-gesture
|
|
1778
2040
|
* snapshot is provided as `previousRendered`; `commitGesture` has already
|
|
1779
2041
|
* mutated `workingPoints` and re-rendered preview / handle layers before
|
|
@@ -1839,19 +2101,20 @@ function runEdit(opts) {
|
|
|
1839
2101
|
* queued during the drag are applied after the listener emission (so the
|
|
1840
2102
|
* post-gesture render reflects the requested mode set).
|
|
1841
2103
|
*/
|
|
1842
|
-
function commitGesture(nextPoints) {
|
|
2104
|
+
function commitGesture(nextPoints, preGestureOptions) {
|
|
1843
2105
|
if (ctx.settled) return;
|
|
1844
2106
|
const previousPoints = workingPoints.map((p) => [...p]);
|
|
1845
|
-
const previousOptions = { ...workingOptions ?? {} };
|
|
2107
|
+
const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
|
|
1846
2108
|
const previousStyle = currentStyle;
|
|
1847
2109
|
const previousNonGeometryDirty = nonGeometryDirty;
|
|
1848
|
-
const previousRendered =
|
|
2110
|
+
const previousRendered = rebuildRenderedWith(previousOptions);
|
|
1849
2111
|
replaceWorkingPoints(nextPoints);
|
|
1850
2112
|
let currentRendered;
|
|
1851
2113
|
try {
|
|
1852
2114
|
currentRendered = rebuildRendered();
|
|
1853
2115
|
} catch (err) {
|
|
1854
2116
|
replaceWorkingPoints(previousPoints);
|
|
2117
|
+
if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
|
|
1855
2118
|
dragInFlight = false;
|
|
1856
2119
|
drainPendingModes();
|
|
1857
2120
|
throw err;
|
|
@@ -1941,16 +2204,23 @@ function runEdit(opts) {
|
|
|
1941
2204
|
}));
|
|
1942
2205
|
}
|
|
1943
2206
|
/**
|
|
1944
|
-
*
|
|
1945
|
-
* 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
|
|
1946
2211
|
* `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
|
|
1947
|
-
* 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.
|
|
1948
2214
|
*/
|
|
1949
2215
|
function commitRotate(angle) {
|
|
1950
2216
|
if (ctx.settled) return;
|
|
1951
|
-
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);
|
|
1952
2221
|
if (!pivot) return;
|
|
1953
|
-
|
|
2222
|
+
boxAngle += angle;
|
|
2223
|
+
commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
|
|
1954
2224
|
}
|
|
1955
2225
|
function modesEqual(a, b) {
|
|
1956
2226
|
if (a.length !== b.length) return false;
|
|
@@ -2042,13 +2312,14 @@ function runEdit(opts) {
|
|
|
2042
2312
|
};
|
|
2043
2313
|
let activeDrag = null;
|
|
2044
2314
|
/**
|
|
2045
|
-
*
|
|
2046
|
-
*
|
|
2047
|
-
*
|
|
2048
|
-
*
|
|
2049
|
-
*
|
|
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`.
|
|
2050
2321
|
*/
|
|
2051
|
-
let
|
|
2322
|
+
let activeBoxDrag = null;
|
|
2052
2323
|
/**
|
|
2053
2324
|
* Whether the vertex at `index` may be deleted (via Alt+click or in the
|
|
2054
2325
|
* exclusive `delete` mode). Deletion is
|
|
@@ -2087,74 +2358,149 @@ function runEdit(opts) {
|
|
|
2087
2358
|
}
|
|
2088
2359
|
const onDriverDown = (pixel, info) => {
|
|
2089
2360
|
if (ctx.settled || dragInFlight) return;
|
|
2090
|
-
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
|
|
2361
|
+
const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
|
|
2091
2362
|
const hit = pickHandle(buildHandleSet(workingPoints, modes, {
|
|
2092
2363
|
includeMidpoints: midpointHandlesEnabled,
|
|
2093
2364
|
trailingFixedSlots,
|
|
2094
2365
|
closedRing
|
|
2095
2366
|
}), pixel, tol, (lonLat) => driver.toPixel(lonLat));
|
|
2096
|
-
if (
|
|
2097
|
-
|
|
2098
|
-
|
|
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)) {
|
|
2099
2376
|
deleteVertex(hit.index);
|
|
2100
2377
|
suppressClickAway = true;
|
|
2378
|
+
return;
|
|
2101
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);
|
|
2102
2399
|
return;
|
|
2103
2400
|
}
|
|
2104
|
-
if (
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
+
}
|
|
2125
2432
|
}
|
|
2126
|
-
dragInFlight = true;
|
|
2127
|
-
driver.setDragPanEnabled(false);
|
|
2128
2433
|
};
|
|
2129
2434
|
function paintDragSnapshot(pixel) {
|
|
2130
2435
|
if (!activeDrag) return;
|
|
2131
2436
|
const snapshot = activeDrag.fsm.move(pixel);
|
|
2132
2437
|
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
2133
|
-
|
|
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);
|
|
2134
2447
|
renderPreview();
|
|
2135
2448
|
renderHandlesLayer();
|
|
2136
2449
|
recomputeDirty();
|
|
2137
2450
|
}
|
|
2138
2451
|
const onDriverMove = (pixel) => {
|
|
2139
|
-
if (ctx.settled
|
|
2140
|
-
paintDragSnapshot(pixel);
|
|
2452
|
+
if (ctx.settled) return;
|
|
2453
|
+
if (activeDrag) paintDragSnapshot(pixel);
|
|
2454
|
+
else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
|
|
2141
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
|
+
}
|
|
2142
2462
|
const onDriverUp = (pixel) => {
|
|
2143
|
-
if (
|
|
2144
|
-
|
|
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;
|
|
2145
2471
|
activeDrag = null;
|
|
2146
|
-
rotateHandleOverride = null;
|
|
2147
|
-
dragInFlight = false;
|
|
2148
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);
|
|
2149
2502
|
return;
|
|
2150
2503
|
}
|
|
2151
|
-
const { fsm, preDragPoints } = activeDrag;
|
|
2152
|
-
activeDrag = null;
|
|
2153
|
-
rotateHandleOverride = null;
|
|
2154
|
-
driver.setDragPanEnabled(true);
|
|
2155
|
-
const next = fsm.end(pixel);
|
|
2156
|
-
replaceWorkingPoints(preDragPoints);
|
|
2157
|
-
commitGesture(next);
|
|
2158
2504
|
};
|
|
2159
2505
|
let pickPreview;
|
|
2160
2506
|
let pickHandles;
|
|
@@ -2218,10 +2564,10 @@ function runEdit(opts) {
|
|
|
2218
2564
|
unsubDown();
|
|
2219
2565
|
unsubMove();
|
|
2220
2566
|
unsubUp();
|
|
2221
|
-
if (activeDrag !== null) {
|
|
2567
|
+
if (activeDrag !== null || activeBoxDrag !== null) {
|
|
2222
2568
|
driver.setDragPanEnabled(true);
|
|
2223
2569
|
activeDrag = null;
|
|
2224
|
-
|
|
2570
|
+
activeBoxDrag = null;
|
|
2225
2571
|
dragInFlight = false;
|
|
2226
2572
|
}
|
|
2227
2573
|
driver.dispose();
|
|
@@ -2249,12 +2595,17 @@ function runEdit(opts) {
|
|
|
2249
2595
|
}
|
|
2250
2596
|
};
|
|
2251
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
|
+
*/
|
|
2252
2606
|
function normaliseModes(modes) {
|
|
2253
|
-
if (!modes || modes.length === 0) return ["reshape"];
|
|
2254
|
-
for (const m of modes) {
|
|
2255
|
-
if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2256
|
-
if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
|
|
2257
|
-
}
|
|
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)}"`);
|
|
2258
2609
|
if (modes.includes("delete")) return ["delete"];
|
|
2259
2610
|
const seen = /* @__PURE__ */ new Set();
|
|
2260
2611
|
const out = [];
|
|
@@ -2282,6 +2633,531 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
|
|
|
2282
2633
|
return true;
|
|
2283
2634
|
}
|
|
2284
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
|
|
2285
3161
|
//#region src/tactical-draw/errors.ts
|
|
2286
3162
|
/**
|
|
2287
3163
|
* Error thrown when any `TacticalDraw` method is called after `destroy()`.
|
|
@@ -2324,6 +3200,20 @@ var TacticalDraw = class {
|
|
|
2324
3200
|
*/
|
|
2325
3201
|
activeEdit = null;
|
|
2326
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
|
+
/**
|
|
2327
3217
|
* Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
|
|
2328
3218
|
* façade subscribes once to `adapter.onPick` per layer and routes events
|
|
2329
3219
|
* through this map so that close-then-pick ordering during an active edit can
|
|
@@ -2341,6 +3231,7 @@ var TacticalDraw = class {
|
|
|
2341
3231
|
deferredGraphicsPick = null;
|
|
2342
3232
|
idCounter = 0;
|
|
2343
3233
|
editCounter = 0;
|
|
3234
|
+
transformCounter = 0;
|
|
2344
3235
|
/**
|
|
2345
3236
|
* Snapshot of the host's last `render()` argument. The edit slice uses it
|
|
2346
3237
|
* to recompute the graphics-layer state during a lift / restore — the
|
|
@@ -2392,12 +3283,24 @@ var TacticalDraw = class {
|
|
|
2392
3283
|
*/
|
|
2393
3284
|
handleViewChange = () => {
|
|
2394
3285
|
if (this.destroyed || !this.hasRendered) return;
|
|
2395
|
-
const
|
|
2396
|
-
const visible =
|
|
3286
|
+
const excluded = this.excludedInteractionIds();
|
|
3287
|
+
const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
|
|
2397
3288
|
if (!visible.some(measureUsesAdapterResolution)) return;
|
|
2398
3289
|
this.renderMeasuresToGraphics(visible);
|
|
2399
3290
|
};
|
|
2400
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
|
+
/**
|
|
2401
3304
|
* Reconcile the graphics layer against the authoritative list of measures.
|
|
2402
3305
|
* Idempotent. Throws synchronously on duplicate measure ids *before*
|
|
2403
3306
|
* mutating any layer. An empty list before any prior render is a no-op.
|
|
@@ -2436,7 +3339,10 @@ var TacticalDraw = class {
|
|
|
2436
3339
|
externalEdited = measures.find((m) => m.id === editedId);
|
|
2437
3340
|
editedRemoved = externalEdited === void 0;
|
|
2438
3341
|
}
|
|
2439
|
-
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;
|
|
2440
3346
|
if (!(visible.length === 0 && !this.hasRendered)) {
|
|
2441
3347
|
this.renderMeasuresToGraphics(visible);
|
|
2442
3348
|
this.hasRendered = true;
|
|
@@ -2449,6 +3355,12 @@ var TacticalDraw = class {
|
|
|
2449
3355
|
editHandle.abort("removed");
|
|
2450
3356
|
});
|
|
2451
3357
|
}
|
|
3358
|
+
if (transformMemberRemoved && this.activeTransform) {
|
|
3359
|
+
const transformHandle = this.activeTransform;
|
|
3360
|
+
queueMicrotask(() => {
|
|
3361
|
+
transformHandle.abort("removed");
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
2452
3364
|
}
|
|
2453
3365
|
/**
|
|
2454
3366
|
* Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
|
|
@@ -2466,6 +3378,8 @@ var TacticalDraw = class {
|
|
|
2466
3378
|
}
|
|
2467
3379
|
if (this.activeInteraction.isActive) {
|
|
2468
3380
|
this.activeEdit = null;
|
|
3381
|
+
this.activeTransform = null;
|
|
3382
|
+
this.activeTransformSessionRef = null;
|
|
2469
3383
|
this.activeInteraction.cancel("preempted");
|
|
2470
3384
|
}
|
|
2471
3385
|
return null;
|
|
@@ -2481,14 +3395,17 @@ var TacticalDraw = class {
|
|
|
2481
3395
|
* whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
|
|
2482
3396
|
* caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
|
|
2483
3397
|
*/
|
|
2484
|
-
runWithSlot(create,
|
|
3398
|
+
runWithSlot(create, activate, extraSettle) {
|
|
2485
3399
|
let handle;
|
|
2486
|
-
let
|
|
3400
|
+
let deactivate = null;
|
|
2487
3401
|
let settledDuringCreate = false;
|
|
2488
3402
|
const clearSlot = () => {
|
|
2489
3403
|
if (handle) {
|
|
2490
3404
|
this.activeInteraction.clear(handle);
|
|
2491
|
-
if (
|
|
3405
|
+
if (deactivate) {
|
|
3406
|
+
deactivate();
|
|
3407
|
+
deactivate = null;
|
|
3408
|
+
}
|
|
2492
3409
|
} else {
|
|
2493
3410
|
settledDuringCreate = true;
|
|
2494
3411
|
this.activeInteraction.clear();
|
|
@@ -2501,7 +3418,6 @@ var TacticalDraw = class {
|
|
|
2501
3418
|
this.activeInteraction.begin();
|
|
2502
3419
|
try {
|
|
2503
3420
|
handle = create(onSettled);
|
|
2504
|
-
editHandle = editHandleOf ? editHandleOf(handle) : null;
|
|
2505
3421
|
} catch (error) {
|
|
2506
3422
|
this.activeInteraction.clear();
|
|
2507
3423
|
if (!settledDuringCreate && extraSettle) extraSettle();
|
|
@@ -2509,7 +3425,7 @@ var TacticalDraw = class {
|
|
|
2509
3425
|
}
|
|
2510
3426
|
if (!settledDuringCreate) {
|
|
2511
3427
|
this.activeInteraction.set(handle);
|
|
2512
|
-
if (
|
|
3428
|
+
if (activate && !handle.settled) deactivate = activate(handle) ?? null;
|
|
2513
3429
|
}
|
|
2514
3430
|
handle.promise.then(clearSlot, clearSlot);
|
|
2515
3431
|
return handle.promise;
|
|
@@ -2525,6 +3441,18 @@ var TacticalDraw = class {
|
|
|
2525
3441
|
};
|
|
2526
3442
|
}
|
|
2527
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
|
+
/**
|
|
2528
3456
|
* Start an interactive draw. Fixed-length kinds commit automatically on the
|
|
2529
3457
|
* last required click. Variable-length kinds surface a `DrawSession` via
|
|
2530
3458
|
* `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
|
|
@@ -2551,6 +3479,7 @@ var TacticalDraw = class {
|
|
|
2551
3479
|
adapter: this.adapter,
|
|
2552
3480
|
kind,
|
|
2553
3481
|
previewLayer: this.previewLayer,
|
|
3482
|
+
guideLayer: this.guideLayer,
|
|
2554
3483
|
graphicsStyle: this.graphicsStyle,
|
|
2555
3484
|
generateId: () => draft.id ?? this.generateId(),
|
|
2556
3485
|
signal: options?.signal,
|
|
@@ -2590,9 +3519,7 @@ var TacticalDraw = class {
|
|
|
2590
3519
|
*/
|
|
2591
3520
|
edit(measure, options) {
|
|
2592
3521
|
this.assertAlive();
|
|
2593
|
-
|
|
2594
|
-
for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
|
|
2595
|
-
}
|
|
3522
|
+
normaliseModes(options?.modes);
|
|
2596
3523
|
const preempt = this.guardAndPreempt();
|
|
2597
3524
|
if (preempt) return preempt;
|
|
2598
3525
|
const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
|
|
@@ -2625,7 +3552,71 @@ var TacticalDraw = class {
|
|
|
2625
3552
|
wasOnGraphicsLayer,
|
|
2626
3553
|
onSettled,
|
|
2627
3554
|
onClickAwayGraphicsPick
|
|
2628
|
-
}), (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);
|
|
2629
3620
|
}
|
|
2630
3621
|
/**
|
|
2631
3622
|
* Subscribe to picks on committed control measures.
|
|
@@ -2693,6 +3684,15 @@ var TacticalDraw = class {
|
|
|
2693
3684
|
return this.activeInteraction.session;
|
|
2694
3685
|
}
|
|
2695
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
|
+
/**
|
|
2696
3696
|
* Tear down the façade. Releases only layer slots the façade created; host
|
|
2697
3697
|
* supplied layer ids are left untouched. Safe to call multiple times. After
|
|
2698
3698
|
* the first call every public method throws `TacticalDrawDestroyedError`.
|
|
@@ -2702,6 +3702,8 @@ var TacticalDraw = class {
|
|
|
2702
3702
|
this.destroyed = true;
|
|
2703
3703
|
if (this.activeInteraction.isActive) {
|
|
2704
3704
|
this.activeEdit = null;
|
|
3705
|
+
this.activeTransform = null;
|
|
3706
|
+
this.activeTransformSessionRef = null;
|
|
2705
3707
|
this.activeInteraction.cancel("destroyed");
|
|
2706
3708
|
} else this.activeInteraction.clear();
|
|
2707
3709
|
this.adapter.offViewChange(this.handleViewChange);
|
|
@@ -2721,7 +3723,8 @@ var TacticalDraw = class {
|
|
|
2721
3723
|
* from a host pick handler observes a settled session.
|
|
2722
3724
|
*/
|
|
2723
3725
|
routePick(layerId, event) {
|
|
2724
|
-
|
|
3726
|
+
const excluded = this.excludedInteractionIds();
|
|
3727
|
+
if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
|
|
2725
3728
|
this.deferredGraphicsPick = event;
|
|
2726
3729
|
return;
|
|
2727
3730
|
}
|
|
@@ -2760,9 +3763,19 @@ var TacticalDraw = class {
|
|
|
2760
3763
|
return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
|
|
2761
3764
|
}
|
|
2762
3765
|
/**
|
|
2763
|
-
* Resolve handle slots across built-in → façade →
|
|
2764
|
-
* per-property shallow merge. Explicit `undefined`
|
|
2765
|
-
* 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.
|
|
2766
3779
|
*
|
|
2767
3780
|
* Returns `undefined` only when no slot would carry any value — keeps the
|
|
2768
3781
|
* downstream "no style on the feature" path intact for tests that assert
|
|
@@ -2774,6 +3787,10 @@ var TacticalDraw = class {
|
|
|
2774
3787
|
const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
|
|
2775
3788
|
if (merged) out[slot] = merged;
|
|
2776
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;
|
|
2777
3794
|
return out;
|
|
2778
3795
|
}
|
|
2779
3796
|
assertAlive() {
|
|
@@ -2790,10 +3807,12 @@ const BUILT_IN_GRAPHICS_STYLE = {
|
|
|
2790
3807
|
strokeWidth: 2
|
|
2791
3808
|
};
|
|
2792
3809
|
/**
|
|
2793
|
-
* Built-in defaults for the interaction-affordance slots
|
|
2794
|
-
*
|
|
2795
|
-
*
|
|
2796
|
-
*
|
|
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.
|
|
2797
3816
|
*/
|
|
2798
3817
|
const BUILT_IN_INTERACTION_STYLE = {
|
|
2799
3818
|
guide: {
|
|
@@ -2801,6 +3820,10 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2801
3820
|
strokeWidth: 1,
|
|
2802
3821
|
strokeDash: [6, 4]
|
|
2803
3822
|
},
|
|
3823
|
+
boxOutline: {
|
|
3824
|
+
strokeColor: "#4f7cff",
|
|
3825
|
+
strokeWidth: 1
|
|
3826
|
+
},
|
|
2804
3827
|
vertexHandle: {
|
|
2805
3828
|
pointRadius: 6,
|
|
2806
3829
|
fillColor: "#ffffff",
|
|
@@ -2813,13 +3836,14 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2813
3836
|
strokeColor: "#6b7280",
|
|
2814
3837
|
strokeWidth: 1.5
|
|
2815
3838
|
},
|
|
2816
|
-
|
|
2817
|
-
pointRadius:
|
|
2818
|
-
fillColor: "#
|
|
2819
|
-
strokeColor: "#
|
|
3839
|
+
scaleHandle: {
|
|
3840
|
+
pointRadius: 5,
|
|
3841
|
+
fillColor: "#ffffff",
|
|
3842
|
+
strokeColor: "#4f7cff",
|
|
3843
|
+
strokeWidth: 2
|
|
2820
3844
|
},
|
|
2821
3845
|
rotateHandle: {
|
|
2822
|
-
pointRadius:
|
|
3846
|
+
pointRadius: 5,
|
|
2823
3847
|
fillColor: "#16a34a",
|
|
2824
3848
|
strokeColor: "#ffffff"
|
|
2825
3849
|
}
|
|
@@ -2827,7 +3851,7 @@ const BUILT_IN_INTERACTION_STYLE = {
|
|
|
2827
3851
|
const HANDLE_SLOTS = [
|
|
2828
3852
|
"vertexHandle",
|
|
2829
3853
|
"midpointHandle",
|
|
2830
|
-
"
|
|
3854
|
+
"scaleHandle",
|
|
2831
3855
|
"rotateHandle"
|
|
2832
3856
|
];
|
|
2833
3857
|
/**
|