@orbat-mapper/control-measures 0.10.0 → 0.12.0

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.
@@ -130,15 +130,27 @@ const destinationPoint = (origin, distance, bearing) => {
130
130
  //#endregion
131
131
  //#region src/internal/vector-utils.ts
132
132
  /**
133
- * Offsets a polyline using Miter Joins or Round Joins.
133
+ * Offsets a polyline to both sides at once, using Miter Joins or Round Joins.
134
+ * `offsets` carries one half-width per vertex, so the outline may taper along
135
+ * its centerline; both sides read the same array rather than materialising a
136
+ * negated copy per geometry pass.
134
137
  */
135
- function offsetPolyline(points, offset, rounded = false, segments = 5) {
138
+ function offsetPolylineSides(points, offsets, rounded = false, segments = 5) {
139
+ return {
140
+ left: offsetPolylineWithOffsets(points, offsets, rounded, segments, 1),
141
+ right: offsetPolylineWithOffsets(points, offsets, rounded, segments, -1)
142
+ };
143
+ }
144
+ /** One side of {@link offsetPolylineSides}; `sign` picks which. */
145
+ function offsetPolylineWithOffsets(points, offsets, rounded, segments, sign) {
136
146
  if (points.length < 2) return points;
147
+ if (offsets.length !== points.length) return points;
137
148
  const result = [];
138
149
  const N = points.length;
139
150
  for (let i = 0; i < N; i++) {
140
151
  const p = points[i];
141
152
  if (!p) continue;
153
+ const offset = offsets[i] * sign;
142
154
  if (i === 0) {
143
155
  const next = points[i + 1];
144
156
  if (next) {
@@ -195,6 +207,17 @@ function offsetPolyline(points, offset, rounded = false, segments = 5) {
195
207
  }
196
208
  return result;
197
209
  }
210
+ /** Linearly interpolates values by distance along a polyline, not vertex count. */
211
+ function interpolatePolylineValues(points, startValue, endValue) {
212
+ const distances = [0];
213
+ for (let i = 1; i < points.length; i++) distances.push(distances[i - 1] + vecMag(vecSub(points[i], points[i - 1])));
214
+ const totalLength = distances.at(-1) ?? 0;
215
+ if (totalLength < 1e-6) return points.map(() => endValue);
216
+ return distances.map((distance) => {
217
+ const progress = distance / totalLength;
218
+ return startValue + (endValue - startValue) * progress;
219
+ });
220
+ }
198
221
  function vecAdd(a, b) {
199
222
  return [a[0] + b[0], a[1] + b[1]];
200
223
  }
@@ -248,9 +271,81 @@ function lineIntersection(p1, p2, p3, p4) {
248
271
  }
249
272
  //#endregion
250
273
  //#region src/attack-utils.ts
251
- const DEFAULT_SHAFT_WIDTH_RATIO = .6;
274
+ const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
275
+ const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
252
276
  const SHAFT_MIN_RATIO = .1;
253
277
  const SHAFT_MAX_RATIO = .9;
278
+ const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
279
+ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
280
+ /**
281
+ * Creates the definition-owned rear-/shaft-width handle pair shared by every
282
+ * variable-width arrow body. Callers supply only how their own geometry maps
283
+ * onto {@link WidthHandleGeometry}; placement (the two ends of the left edge)
284
+ * and the drag math (perpendicular distance from the dragged point to the
285
+ * matching centerline segment, over the reference half-width) live here once.
286
+ */
287
+ function createWidthOptionHandles(config) {
288
+ return {
289
+ get(controlPoints, options) {
290
+ const geometry = config.geometry(controlPoints, options);
291
+ const rearLeft = geometry?.leftEdge[0];
292
+ const neckLeft = geometry?.leftEdge.at(-1);
293
+ if (!rearLeft || !neckLeft) return [];
294
+ return [{
295
+ id: REAR_WIDTH_OPTION_HANDLE_ID,
296
+ position: unproject(rearLeft[0], rearLeft[1])
297
+ }, {
298
+ id: SHAFT_WIDTH_OPTION_HANDLE_ID,
299
+ position: unproject(neckLeft[0], neckLeft[1])
300
+ }];
301
+ },
302
+ drag({ controlPoints, options, handleId, position }) {
303
+ const geometry = config.geometry(controlPoints, options);
304
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
305
+ const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
306
+ if (!rear && !(handleId === "shaft-width")) return void 0;
307
+ const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
308
+ const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
309
+ if (!from || !to) return void 0;
310
+ const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
311
+ return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
312
+ }
313
+ };
314
+ }
315
+ /**
316
+ * The width handles for attack bodies built by {@link processAttackGeometry},
317
+ * whose ratios are fractions of the arrowhead half-width. A coordinate resolver
318
+ * lets derivative measures (Counterattack by Fire) map their authored Axis1
319
+ * points to the body coordinates first.
320
+ */
321
+ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) => points) {
322
+ return createWidthOptionHandles({
323
+ geometry(controlPoints, options) {
324
+ const coordinates = resolveCoordinates(controlPoints);
325
+ if (!coordinates) return null;
326
+ const { geometry } = processAttackGeometry(coordinates, options);
327
+ const outerLeft = geometry?.headRing[0];
328
+ if (!geometry || !outerLeft) return null;
329
+ return {
330
+ spine: geometry.shaftCenterline,
331
+ leftEdge: geometry.shaftLeft,
332
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
333
+ };
334
+ },
335
+ minRatio: SHAFT_MIN_RATIO,
336
+ maxRearRatio: 3,
337
+ maxShaftRatio: SHAFT_MAX_RATIO
338
+ });
339
+ }
340
+ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles();
341
+ /**
342
+ * The single home of the "rear inherits shaft" rule: an omitted `rearWidthRatio`
343
+ * leaves the body parallel by matching the effective shaft ratio. Every
344
+ * variable-width body resolves the option through here so the rule cannot drift.
345
+ */
346
+ function resolveRearWidthRatio(options, shaftRatio) {
347
+ return options.rearWidthRatio ?? shaftRatio;
348
+ }
254
349
  /**
255
350
  * Processes input coordinates and options to generate the core symbol geometry.
256
351
  * This handles validation, default values, projection, and geometry calculation.
@@ -259,6 +354,7 @@ function processAttackGeometry(coordinates, options = {}) {
259
354
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
260
355
  const smooth = options.smooth ?? false;
261
356
  const smoothResolution = options.smoothResolution ?? 5;
357
+ const rearRatio = clamp(resolveRearWidthRatio(options, shaftRatio), SHAFT_MIN_RATIO, 3);
262
358
  const points = coordinates.map((c) => project(c[0], c[1]));
263
359
  const numPoints = points.length;
264
360
  const ptTip = points[0];
@@ -268,9 +364,9 @@ function processAttackGeometry(coordinates, options = {}) {
268
364
  const p = points[i];
269
365
  if (p) spinePoints.push(p);
270
366
  }
271
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, smooth, smoothResolution) };
367
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
272
368
  }
273
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoothResolution) {
369
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
274
370
  const initialNeck = spine[spine.length - 1];
275
371
  if (!initialNeck) return null;
276
372
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -323,10 +419,11 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoo
323
419
  outerLeft
324
420
  ];
325
421
  const fullSpine = [...remainingSpine, shaftEndCenter];
422
+ const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
326
423
  return {
327
424
  shaftCenterline: fullSpine,
328
- shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, smooth, smoothResolution),
329
- shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, smooth, smoothResolution),
425
+ shaftLeft,
426
+ shaftRight,
330
427
  headRing,
331
428
  ptTip,
332
429
  ptNeck,
@@ -563,7 +660,7 @@ function samePosition(a, b) {
563
660
  }
564
661
  //#endregion
565
662
  //#region src/draw-rules/area1.ts
566
- function derive$12(points) {
663
+ function derive$14(points) {
567
664
  return clonePositions(points);
568
665
  }
569
666
  /**
@@ -583,9 +680,9 @@ const area1DrawRule = {
583
680
  id: "area1",
584
681
  minimumUserPoints: 3,
585
682
  closedRing: true,
586
- derive: derive$12,
683
+ derive: derive$14,
587
684
  transform(event) {
588
- return derive$12(event.next);
685
+ return derive$14(event.next);
589
686
  }
590
687
  };
591
688
  //#endregion
@@ -625,14 +722,14 @@ const containDrawRule = createMidpointPerpendicularDrawRule({
625
722
  * that fixes both the radius and the bearing of the symbol's opening. Both
626
723
  * points are user-clicked, so the canonical array is just the (clamped) input.
627
724
  */
628
- function derive$11(points) {
725
+ function derive$13(points) {
629
726
  return clonePositions(points.slice(0, 2));
630
727
  }
631
728
  const centerRadiusDrawRule = {
632
729
  id: "area15:center-radius",
633
730
  minimumUserPoints: 2,
634
731
  canonicalPointCount: 2,
635
- derive: derive$11,
732
+ derive: derive$13,
636
733
  transform(event) {
637
734
  const { previous, next, activePointIndex } = event;
638
735
  if (activePointIndex === 0 && previous.length >= 2 && next.length >= 2) {
@@ -641,7 +738,7 @@ const centerRadiusDrawRule = {
641
738
  const origin = previous[1];
642
739
  return [clonePosition(next[0]), [origin[0] + dx, origin[1] + dy]];
643
740
  }
644
- return derive$11(next);
741
+ return derive$13(next);
645
742
  }
646
743
  };
647
744
  /** Backward-compatible doctrinal name for Area15's shared center-radius rule. */
@@ -705,16 +802,16 @@ const point12DrawRule = createMidpointPerpendicularDrawRule({ id: "point12:obsta
705
802
  * Point1 — a single anchor point (e.g. Text's center). One user click commits
706
803
  * the measure; dragging the point simply translates it.
707
804
  */
708
- function derive$10(points) {
805
+ function derive$12(points) {
709
806
  return clonePositions(points.slice(0, 1));
710
807
  }
711
808
  const pointDrawRule = {
712
809
  id: "point1:anchor",
713
810
  minimumUserPoints: 1,
714
811
  canonicalPointCount: 1,
715
- derive: derive$10,
812
+ derive: derive$12,
716
813
  transform(event) {
717
- return derive$10(event.next);
814
+ return derive$12(event.next);
718
815
  }
719
816
  };
720
817
  //#endregion
@@ -741,7 +838,7 @@ const SECTOR_ANGLE_SNAP_RADIANS = Math.PI / 36;
741
838
  * independently control a radius and a true-north azimuth. Dragging the anchor
742
839
  * translates both edge points so the sector retains its size and orientation.
743
840
  */
744
- function derive$9(points) {
841
+ function derive$11(points) {
745
842
  return clonePositions(points.slice(0, 3));
746
843
  }
747
844
  function guidePoints$2(points) {
@@ -769,7 +866,7 @@ const sectorDrawRule = {
769
866
  showGuide: true,
770
867
  guidePoints: guidePoints$2,
771
868
  constrainAngles,
772
- derive: derive$9,
869
+ derive: derive$11,
773
870
  transform(event) {
774
871
  const { previous, next, activePointIndex } = event;
775
872
  if (activePointIndex === 0 && previous.length >= 3 && next.length >= 3) {
@@ -781,7 +878,7 @@ const sectorDrawRule = {
781
878
  [previous[2][0] + dx, previous[2][1] + dy]
782
879
  ];
783
880
  }
784
- return derive$9(next);
881
+ return derive$11(next);
785
882
  }
786
883
  };
787
884
  //#endregion
@@ -836,20 +933,20 @@ const ambushDrawRule = createMidpointPerpendicularDrawRule({
836
933
  });
837
934
  //#endregion
838
935
  //#region src/draw-rules/line1.ts
839
- function derive$8(points) {
936
+ function derive$10(points) {
840
937
  return clonePositions(points);
841
938
  }
842
939
  const line1DrawRule = {
843
940
  id: "line1",
844
941
  minimumUserPoints: 2,
845
- derive: derive$8,
942
+ derive: derive$10,
846
943
  transform(event) {
847
- return derive$8(event.next);
944
+ return derive$10(event.next);
848
945
  }
849
946
  };
850
947
  //#endregion
851
948
  //#region src/draw-rules/line3.ts
852
- function derive$7(points) {
949
+ function derive$9(points) {
853
950
  return clonePositions(points);
854
951
  }
855
952
  /**
@@ -864,23 +961,23 @@ const line3DrawRule = {
864
961
  id: "line3",
865
962
  minimumUserPoints: 3,
866
963
  canonicalPointCount: 3,
867
- derive: derive$7,
964
+ derive: derive$9,
868
965
  transform(event) {
869
- return derive$7(event.next);
966
+ return derive$9(event.next);
870
967
  }
871
968
  };
872
969
  //#endregion
873
970
  //#region src/draw-rules/line9.ts
874
- function derive$6(points) {
971
+ function derive$8(points) {
875
972
  return clonePositions(points);
876
973
  }
877
974
  const line9DrawRule = {
878
975
  id: "line9",
879
976
  minimumUserPoints: 2,
880
977
  canonicalPointCount: 2,
881
- derive: derive$6,
978
+ derive: derive$8,
882
979
  transform(event) {
883
- return derive$6(event.next);
980
+ return derive$8(event.next);
884
981
  }
885
982
  };
886
983
  //#endregion
@@ -1009,7 +1106,7 @@ const line24DrawRule = createMidpointPerpendicularDrawRule({
1009
1106
  });
1010
1107
  //#endregion
1011
1108
  //#region src/draw-rules/line26.ts
1012
- function derive$5(points) {
1109
+ function derive$7(points) {
1013
1110
  if (points.length === 2) {
1014
1111
  const [p1, p2] = points;
1015
1112
  const dx = p2[0] - p1[0];
@@ -1048,9 +1145,9 @@ const line26DrawRule = {
1048
1145
  minimumUserPoints: 4,
1049
1146
  minimumPreviewPoints: 2,
1050
1147
  canonicalPointCount: 4,
1051
- derive: derive$5,
1148
+ derive: derive$7,
1052
1149
  transform(event) {
1053
- return derive$5(event.next);
1150
+ return derive$7(event.next);
1054
1151
  }
1055
1152
  };
1056
1153
  //#endregion
@@ -1063,7 +1160,7 @@ function deriveArcPoint(p1, p2, p4) {
1063
1160
  if (!arc) return [(p2[0] + p4[0]) / 2, (p2[1] + p4[1]) / 2];
1064
1161
  return unproject(arc.midpoint[0], arc.midpoint[1]);
1065
1162
  }
1066
- function derive$4(points) {
1163
+ function derive$6(points) {
1067
1164
  if (points.length < 2) return clonePositions(points);
1068
1165
  const [p1, p2] = points;
1069
1166
  if (points.length === 2) {
@@ -1102,7 +1199,7 @@ const line27DrawRule = {
1102
1199
  minimumUserPoints: 3,
1103
1200
  minimumPreviewPoints: 2,
1104
1201
  canonicalPointCount: 4,
1105
- derive: derive$4,
1202
+ derive: derive$6,
1106
1203
  transform(event) {
1107
1204
  const { previous, next, activePointIndex } = event;
1108
1205
  if (activePointIndex === 0 && previous.length >= 4 && next.length >= 4) {
@@ -1134,6 +1231,124 @@ const line27DrawRule = {
1134
1231
  }
1135
1232
  };
1136
1233
  //#endregion
1234
+ //#region src/draw-rules/line31.ts
1235
+ function derive$5(points) {
1236
+ if (points.length === 2) {
1237
+ const [c1, c3] = points;
1238
+ const start = project(c1[0], c1[1]);
1239
+ const span = vecSub(project(c3[0], c3[1]), start);
1240
+ const p2 = vecAdd(start, vecScale(span, .5));
1241
+ const p4 = vecAdd(vecAdd(start, vecScale(span, .75)), vecScale([-span[1], span[0]], .25));
1242
+ return clonePositions([
1243
+ c1,
1244
+ unproject(p2[0], p2[1]),
1245
+ c3,
1246
+ unproject(p4[0], p4[1])
1247
+ ]);
1248
+ }
1249
+ const derived = clonePositions(points.slice(0, 4));
1250
+ if (derived.length < 3) return derived;
1251
+ const [c1, c2, c3] = derived;
1252
+ const p1 = project(c1[0], c1[1]);
1253
+ const p2 = project(c2[0], c2[1]);
1254
+ const straight = vecSub(p2, p1);
1255
+ if (vecMag(straight) === 0) return derived;
1256
+ const diameter = vecMag(vecSub(project(c3[0], c3[1]), p2));
1257
+ const anchor = vecAdd(p2, vecScale(vecNorm(straight), diameter));
1258
+ derived[2] = unproject(anchor[0], anchor[1]);
1259
+ return derived;
1260
+ }
1261
+ /**
1262
+ * The snapped PT. 4 for a semicircle spanning PT. 2 → PT. 3, in projected
1263
+ * meters. PT. 4 is the top of the selected semicircle, not a free point on its
1264
+ * circumference, so crossing the diameter flips which side holds the arc.
1265
+ * Returns `null` for a degenerate (zero-length) diameter.
1266
+ */
1267
+ function orientationPoint(p2, p3, p4) {
1268
+ const diameter = vecSub(p3, p2);
1269
+ if (diameter[0] === 0 && diameter[1] === 0) return null;
1270
+ const apex = vecAdd(midpoint(p2, p3), vecScale([-diameter[1], diameter[0]], quarterArcSide(p2, p3, p4) / 2));
1271
+ return unproject(apex[0], apex[1]);
1272
+ }
1273
+ function snapOrientationToArc(points) {
1274
+ if (points.length < 4) return points;
1275
+ const snapped = orientationPoint(project(points[1][0], points[1][1]), project(points[2][0], points[2][1]), project(points[3][0], points[3][1]));
1276
+ if (snapped) points[3] = snapped;
1277
+ return points;
1278
+ }
1279
+ /** Line31 anchors for Envelopment; PT. 3 is constrained parallel to PT. 1 → PT. 2. */
1280
+ const line31DrawRule = {
1281
+ id: "line31:envelopment",
1282
+ minimumUserPoints: 2,
1283
+ minimumPreviewPoints: 2,
1284
+ canonicalPointCount: 4,
1285
+ derive: derive$5,
1286
+ transform(event) {
1287
+ const { activePointIndex, next, previous } = event;
1288
+ if (next.length < 4) return derive$5(next);
1289
+ if (activePointIndex === 1) {
1290
+ const points = clonePositions(next.slice(0, 4));
1291
+ const p1 = project(points[0][0], points[0][1]);
1292
+ const p2 = project(points[1][0], points[1][1]);
1293
+ const p3 = project(points[2][0], points[2][1]);
1294
+ const p4 = project(points[3][0], points[3][1]);
1295
+ const span = vecSub(p3, p1);
1296
+ const lengthSquared = vecDot(span, span);
1297
+ if (lengthSquared === 0) return points;
1298
+ const slid = vecAdd(p1, vecScale(span, Math.min(1, Math.max(0, vecDot(vecSub(p2, p1), span) / lengthSquared))));
1299
+ points[1] = unproject(slid[0], slid[1]);
1300
+ const snapped = orientationPoint(slid, p3, p4);
1301
+ if (snapped) points[3] = snapped;
1302
+ return points;
1303
+ }
1304
+ if (activePointIndex === 3) return snapOrientationToArc(clonePositions(next.slice(0, 4)));
1305
+ if ((activePointIndex === 0 || activePointIndex === 2) && previous.length >= 4) {
1306
+ const oldP1 = project(previous[0][0], previous[0][1]);
1307
+ const oldP2 = project(previous[1][0], previous[1][1]);
1308
+ const oldSpan = vecMag(vecSub(project(previous[2][0], previous[2][1]), oldP1));
1309
+ const straightRatio = oldSpan === 0 ? .5 : vecMag(vecSub(oldP2, oldP1)) / oldSpan;
1310
+ const points = clonePositions(next.slice(0, 4));
1311
+ const p1 = project(points[0][0], points[0][1]);
1312
+ const p3 = project(points[2][0], points[2][1]);
1313
+ const p4 = project(points[3][0], points[3][1]);
1314
+ const slid = vecAdd(p1, vecScale(vecSub(p3, p1), straightRatio));
1315
+ points[1] = unproject(slid[0], slid[1]);
1316
+ const snapped = orientationPoint(slid, p3, p4);
1317
+ if (snapped) points[3] = snapped;
1318
+ return points;
1319
+ }
1320
+ return snapOrientationToArc(derive$5(next));
1321
+ }
1322
+ };
1323
+ //#endregion
1324
+ //#region src/draw-rules/line32.ts
1325
+ function derive$4(points) {
1326
+ if (points.length === 2) {
1327
+ const [c1, c3] = points;
1328
+ const p2 = midpoint(project(c1[0], c1[1]), project(c3[0], c3[1]));
1329
+ return clonePositions([
1330
+ c1,
1331
+ unproject(p2[0], p2[1]),
1332
+ c3
1333
+ ]);
1334
+ }
1335
+ return clonePositions(points.slice(0, 3));
1336
+ }
1337
+ /** Line32 anchors for the two-click Infiltration mission task. */
1338
+ const line32DrawRule = {
1339
+ id: "line32:infiltration",
1340
+ minimumUserPoints: 2,
1341
+ minimumPreviewPoints: 2,
1342
+ canonicalPointCount: 3,
1343
+ hiddenControlPointIndices: [1],
1344
+ derive: derive$4,
1345
+ transform(event) {
1346
+ const { activePointIndex, next } = event;
1347
+ if (next.length >= 3 && (activePointIndex === 0 || activePointIndex === 2)) return derive$4([next[0], next[2]]);
1348
+ return derive$4(next);
1349
+ }
1350
+ };
1351
+ //#endregion
1137
1352
  //#region src/draw-rules/area8.ts
1138
1353
  function derive$3(points) {
1139
1354
  if (points.length < 2) return clonePositions(points);
@@ -1608,6 +1823,16 @@ const ATTACK_SHAFT_PARAMS = [
1608
1823
  max: 1,
1609
1824
  step: .05
1610
1825
  },
1826
+ {
1827
+ key: "rearWidthRatio",
1828
+ presentationTier: "advanced",
1829
+ label: "Rear width",
1830
+ description: "Width at the rear of the shaft as a ratio of the arrowhead width.",
1831
+ type: "number",
1832
+ min: .1,
1833
+ max: 2,
1834
+ step: .05
1835
+ },
1611
1836
  {
1612
1837
  key: "smooth",
1613
1838
  label: "Smooth",
@@ -1836,7 +2061,8 @@ const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d)
1836
2061
  //#endregion
1837
2062
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
1838
2063
  const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
1839
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
2064
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
2065
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
1840
2066
  smooth: false,
1841
2067
  smoothResolution: 5
1842
2068
  };
@@ -1901,7 +2127,8 @@ const AIRBORNE_ATTACK = defineControlMeasure({
1901
2127
  metadata: AIRBORNE_ATTACK_METADATA,
1902
2128
  generator: createAirborneAttack,
1903
2129
  defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
1904
- rule: axis1DrawRule
2130
+ rule: axis1DrawRule,
2131
+ optionHandles: variableWidthAttackOptionHandles
1905
2132
  });
1906
2133
  /**
1907
2134
  * Reacts to an **input-contract** violation according to `mode`: `throw`
@@ -5738,7 +5965,8 @@ const ATTACK_BY_FIRE = defineControlMeasure({
5738
5965
  //#endregion
5739
5966
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
5740
5967
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
5741
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
5968
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
5969
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
5742
5970
  smooth: false,
5743
5971
  smoothResolution: 5,
5744
5972
  symbolHeightRatio: .45,
@@ -5957,7 +6185,8 @@ const ATTACK_HELICOPTER = defineControlMeasure({
5957
6185
  metadata: ATTACK_HELICOPTER_METADATA,
5958
6186
  generator: createAttackHelicopter,
5959
6187
  defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
5960
- rule: axis1DrawRule
6188
+ rule: axis1DrawRule,
6189
+ optionHandles: variableWidthAttackOptionHandles
5961
6190
  });
5962
6191
  //#endregion
5963
6192
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
@@ -6592,8 +6821,12 @@ const BRIDGE_OR_GAP = defineControlMeasure({
6592
6821
  const DEFAULT_SMOOTH_RESOLUTION$5 = 12;
6593
6822
  const MIN_SMOOTH_RESOLUTION$1 = 2;
6594
6823
  const MAX_SMOOTH_RESOLUTION$1 = 64;
6824
+ const DEFAULT_SHAFT_WIDTH_RATIO = .06;
6825
+ const MIN_SHAFT_WIDTH_RATIO = .01;
6826
+ const MAX_SHAFT_WIDTH_RATIO = .3;
6595
6827
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
6596
- shaftWidthRatio: .06,
6828
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6829
+ rearWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6597
6830
  arrowheadStyle: "triangle",
6598
6831
  arrowheadWidthRatio: .18,
6599
6832
  arrowheadLengthRatio: .22,
@@ -6625,8 +6858,18 @@ const BLOCK_ARROW_METADATA = {
6625
6858
  label: "Shaft width",
6626
6859
  description: "Shaft band width as a fraction of the total path length",
6627
6860
  type: "number",
6628
- min: .01,
6629
- max: .3,
6861
+ min: MIN_SHAFT_WIDTH_RATIO,
6862
+ max: MAX_SHAFT_WIDTH_RATIO,
6863
+ step: .01
6864
+ },
6865
+ {
6866
+ key: "rearWidthRatio",
6867
+ presentationTier: "advanced",
6868
+ label: "Rear width",
6869
+ description: "Width at the rear of the shaft as a fraction of the total path length",
6870
+ type: "number",
6871
+ min: MIN_SHAFT_WIDTH_RATIO,
6872
+ max: MAX_SHAFT_WIDTH_RATIO,
6630
6873
  step: .01
6631
6874
  },
6632
6875
  {
@@ -6717,25 +6960,42 @@ const BLOCK_ARROW_METADATA = {
6717
6960
  * right side of the shaft.
6718
6961
  */
6719
6962
  function createBlockArrow(coordinates, options = {}) {
6720
- const { shaftWidthRatio, arrowheadStyle, arrowheadWidthRatio, arrowheadLengthRatio, smooth, smoothResolution, filled } = {
6721
- ...DEFAULT_BLOCK_ARROW_OPTIONS,
6722
- ...options
6723
- };
6724
- const axis1 = axis1Geometry(coordinates);
6725
- const axis = arrowAxis(axis1.path);
6726
- if (!axis) return {
6963
+ const resolved = resolveBlockArrowOptions(options);
6964
+ const geometry = calculateBlockArrowGeometry(coordinates, resolved);
6965
+ if (!geometry) return {
6727
6966
  type: "FeatureCollection",
6728
6967
  features: []
6729
6968
  };
6969
+ const { ring } = geometry;
6970
+ return {
6971
+ type: "FeatureCollection",
6972
+ features: [{
6973
+ type: "Feature",
6974
+ properties: {
6975
+ part: "body",
6976
+ fill: resolved.filled
6977
+ },
6978
+ geometry: {
6979
+ type: "Polygon",
6980
+ coordinates: [ring.map((p) => unproject(p[0], p[1]))]
6981
+ }
6982
+ }]
6983
+ };
6984
+ }
6985
+ function calculateBlockArrowGeometry(coordinates, options) {
6986
+ const axis1 = axis1Geometry(coordinates);
6987
+ const axis = arrowAxis(axis1.path);
6988
+ if (!axis) return null;
6730
6989
  const { pts, pathLength, tip, dir, perp, segLength } = axis;
6731
- const requestedHeadLen = axis1.headLength ?? pathLength * arrowheadLengthRatio;
6990
+ const requestedHeadLen = axis1.headLength ?? pathLength * options.arrowheadLengthRatio;
6732
6991
  const headLen = Math.min(requestedHeadLen, segLength * .95);
6733
- const halfShaft = pathLength * shaftWidthRatio / 2;
6734
- const halfHead = axis1.headHalfWidth ?? pathLength * arrowheadWidthRatio / 2;
6992
+ const halfShaft = pathLength * options.shaftWidthRatio / 2;
6993
+ const halfRear = pathLength * options.rearWidthRatio / 2;
6994
+ const halfHead = axis1.headHalfWidth ?? pathLength * options.arrowheadWidthRatio / 2;
6735
6995
  const onAxis = (d, side = 0) => [tip[0] - dir[0] * d + perp[0] * side, tip[1] - dir[1] * d + perp[1] * side];
6736
6996
  let shaftEndDist;
6737
6997
  let headPts;
6738
- switch (arrowheadStyle) {
6998
+ switch (options.arrowheadStyle) {
6739
6999
  case "barbed":
6740
7000
  shaftEndDist = headLen * .55;
6741
7001
  headPts = [
@@ -6793,9 +7053,9 @@ function createBlockArrow(coordinates, options = {}) {
6793
7053
  break;
6794
7054
  }
6795
7055
  let spine = [...pts.slice(0, -1), onAxis(shaftEndDist)];
6796
- if (smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(smoothResolution));
6797
- const leftSide = offsetPolyline(spine, halfShaft);
6798
- const rightSide = offsetPolyline(spine, -halfShaft);
7056
+ if (options.smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(options.smoothResolution));
7057
+ const widths = interpolatePolylineValues(spine, halfRear, halfShaft);
7058
+ const { left: leftSide, right: rightSide } = offsetPolylineSides(spine, widths);
6799
7059
  const ring = [
6800
7060
  ...leftSide,
6801
7061
  ...headPts,
@@ -6803,18 +7063,20 @@ function createBlockArrow(coordinates, options = {}) {
6803
7063
  ];
6804
7064
  ring.push(ring[0]);
6805
7065
  return {
6806
- type: "FeatureCollection",
6807
- features: [{
6808
- type: "Feature",
6809
- properties: {
6810
- part: "body",
6811
- fill: filled
6812
- },
6813
- geometry: {
6814
- type: "Polygon",
6815
- coordinates: [ring.map((p) => unproject(p[0], p[1]))]
6816
- }
6817
- }]
7066
+ ring,
7067
+ spine,
7068
+ leftSide,
7069
+ pathLength
7070
+ };
7071
+ }
7072
+ function resolveBlockArrowOptions(options) {
7073
+ const resolved = {
7074
+ ...DEFAULT_BLOCK_ARROW_OPTIONS,
7075
+ ...options
7076
+ };
7077
+ return {
7078
+ ...resolved,
7079
+ rearWidthRatio: resolveRearWidthRatio(options, resolved.shaftWidthRatio)
6818
7080
  };
6819
7081
  }
6820
7082
  const BLOCK_ARROW = defineControlMeasure({
@@ -6822,6 +7084,20 @@ const BLOCK_ARROW = defineControlMeasure({
6822
7084
  generator: createBlockArrow,
6823
7085
  defaultOptions: DEFAULT_BLOCK_ARROW_OPTIONS,
6824
7086
  rule: axis1DrawRule,
7087
+ optionHandles: createWidthOptionHandles({
7088
+ geometry(controlPoints, options) {
7089
+ const geometry = calculateBlockArrowGeometry(controlPoints, resolveBlockArrowOptions(options));
7090
+ if (!geometry) return null;
7091
+ return {
7092
+ spine: geometry.spine,
7093
+ leftEdge: geometry.leftSide,
7094
+ referenceHalfWidth: geometry.pathLength / 2
7095
+ };
7096
+ },
7097
+ minRatio: MIN_SHAFT_WIDTH_RATIO,
7098
+ maxRearRatio: MAX_SHAFT_WIDTH_RATIO,
7099
+ maxShaftRatio: MAX_SHAFT_WIDTH_RATIO
7100
+ }),
6825
7101
  previewSample: {
6826
7102
  controlPoints: [
6827
7103
  [1.4, 0],
@@ -9274,7 +9550,8 @@ const CLEAR = defineControlMeasure({
9274
9550
  //#endregion
9275
9551
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
9276
9552
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
9277
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
9553
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
9554
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
9278
9555
  smooth: false,
9279
9556
  smoothResolution: 5
9280
9557
  };
@@ -9346,12 +9623,14 @@ const SUPPORTING_ATTACK = defineControlMeasure({
9346
9623
  metadata: SUPPORTING_ATTACK_METADATA,
9347
9624
  generator: createSupportingAttack,
9348
9625
  defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
9349
- rule: axis1DrawRule
9626
+ rule: axis1DrawRule,
9627
+ optionHandles: variableWidthAttackOptionHandles
9350
9628
  });
9351
9629
  //#endregion
9352
9630
  //#region src/generators/cm34-mission-tasks/counterattack.ts
9353
9631
  const DEFAULT_COUNTERATTACK_OPTIONS = {
9354
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
9632
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
9633
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
9355
9634
  smooth: false,
9356
9635
  smoothResolution: 5,
9357
9636
  labelPosition: 1
@@ -9436,6 +9715,7 @@ const COUNTERATTACK = defineControlMeasure({
9436
9715
  generator: createCounterattack,
9437
9716
  defaultOptions: DEFAULT_COUNTERATTACK_OPTIONS,
9438
9717
  rule: axis1DrawRule,
9718
+ optionHandles: variableWidthAttackOptionHandles,
9439
9719
  previewSample: {
9440
9720
  controlPoints: [
9441
9721
  [1, 0],
@@ -9596,6 +9876,7 @@ const COUNTERATTACK_BY_FIRE = defineControlMeasure({
9596
9876
  generator: createCounterattackByFire,
9597
9877
  defaultOptions: DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS,
9598
9878
  rule: counterattackByFireDrawRule,
9879
+ optionHandles: createVariableWidthAttackOptionHandles(counterattackByFireBodyCoordinates),
9599
9880
  previewSample: {
9600
9881
  controlPoints: [
9601
9882
  [1.35, 0],
@@ -10381,6 +10662,299 @@ const DISRUPT_MISSION_TASK = defineControlMeasure({
10381
10662
  previewSample: { controlPoints: [[0, .4], [0, -.4]] }
10382
10663
  });
10383
10664
  //#endregion
10665
+ //#region src/generators/cm34-mission-tasks/envelopment.ts
10666
+ const DEFAULT_ENVELOPMENT_OPTIONS = {
10667
+ arrowheadLengthRatio: .1,
10668
+ arrowheadWidthRatio: .12,
10669
+ labelPosition: .6,
10670
+ labelGapRatio: .18,
10671
+ resolution: 64
10672
+ };
10673
+ const ENVELOPMENT_METADATA = {
10674
+ id: "envelopment",
10675
+ name: "Envelopment",
10676
+ description: "A form of maneuver in which an attacking force avoids an enemy’s principal defense by attacking along an assailable flank.",
10677
+ entity: "Mission Tasks",
10678
+ entityType: "Envelopment",
10679
+ value: "343500",
10680
+ minCoordinates: 4,
10681
+ maxCoordinates: 4,
10682
+ geometry: "line",
10683
+ geometryTypes: ["MultiLineString", "Point"],
10684
+ paints: {
10685
+ stroke: true,
10686
+ fill: "none",
10687
+ text: true
10688
+ },
10689
+ drawRule: "Line31",
10690
+ capturesLabelSize: true,
10691
+ params: [
10692
+ {
10693
+ key: "arrowheadLengthRatio",
10694
+ presentationTier: "advanced",
10695
+ label: "Arrowhead length",
10696
+ description: "Arrowhead length as a ratio of the arc diameter",
10697
+ type: "number",
10698
+ min: .01,
10699
+ max: .5,
10700
+ step: .01
10701
+ },
10702
+ {
10703
+ key: "arrowheadWidthRatio",
10704
+ presentationTier: "advanced",
10705
+ label: "Arrowhead width",
10706
+ description: "Arrowhead width as a ratio of the arc diameter",
10707
+ type: "number",
10708
+ min: .01,
10709
+ max: .5,
10710
+ step: .01
10711
+ },
10712
+ {
10713
+ key: "labelPosition",
10714
+ presentationTier: "advanced",
10715
+ label: "Label position",
10716
+ description: "Position of the E label along the straight portion",
10717
+ type: "number",
10718
+ min: 0,
10719
+ max: 1,
10720
+ step: .01
10721
+ },
10722
+ {
10723
+ key: "labelGapRatio",
10724
+ presentationTier: "advanced",
10725
+ label: "Label gap",
10726
+ description: "Size of the straight-line cutout holding the E label",
10727
+ type: "number",
10728
+ min: 0,
10729
+ max: 1,
10730
+ step: .01
10731
+ },
10732
+ ...ARC_RESOLUTION_PARAMS
10733
+ ]
10734
+ };
10735
+ /** Generates the Envelopment mission task symbol (343500). */
10736
+ function createEnvelopment(coordinates, options = {}) {
10737
+ const [c1, c2, c3, c4] = coordinates;
10738
+ const p1 = project(c1[0], c1[1]);
10739
+ const p2 = project(c2[0], c2[1]);
10740
+ const diameterAnchor = project(c3[0], c3[1]);
10741
+ const p4 = project(c4[0], c4[1]);
10742
+ const straight = vecSub(p2, p1);
10743
+ const straightLength = vecMag(straight);
10744
+ const diameter = vecMag(vecSub(diameterAnchor, p2));
10745
+ if (straightLength < 1e-6 || diameter < 1e-6) return {
10746
+ type: "FeatureCollection",
10747
+ features: []
10748
+ };
10749
+ const straightDirection = vecNorm(straight);
10750
+ const p3 = vecAdd(p2, vecScale(straightDirection, diameter));
10751
+ const labelPoint = vecAdd(p1, vecScale(straight, clamp01(options.labelPosition ?? DEFAULT_ENVELOPMENT_OPTIONS.labelPosition)));
10752
+ const gapHalf = straightLength * clamp01(options.labelGapRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.labelGapRatio) / 2;
10753
+ const gapStart = vecAdd(labelPoint, vecScale(straightDirection, -gapHalf));
10754
+ const gapEnd = vecAdd(labelPoint, vecScale(straightDirection, gapHalf));
10755
+ const center = midpoint(p2, p3);
10756
+ const startRadius = vecSub(p2, center);
10757
+ let bulgeRadius = [-startRadius[1], startRadius[0]];
10758
+ if (vecDot(vecSub(p4, center), bulgeRadius) < 0) bulgeRadius = vecScale(bulgeRadius, -1);
10759
+ const arc = sampleProjectedArc(center, startRadius, bulgeRadius, 0, Math.PI, normalizeArcResolution(options.resolution) / 2);
10760
+ const arrowhead = createArrowHead$1(p3, vecNorm(vecScale(bulgeRadius, -1)), diameter * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadLengthRatio), diameter * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadWidthRatio));
10761
+ return {
10762
+ type: "FeatureCollection",
10763
+ features: [{
10764
+ type: "Feature",
10765
+ properties: { part: "envelopment" },
10766
+ geometry: {
10767
+ type: "MultiLineString",
10768
+ coordinates: [
10769
+ [c1, unproject(gapStart[0], gapStart[1])],
10770
+ [unproject(gapEnd[0], gapEnd[1]), c2],
10771
+ arc,
10772
+ arrowhead
10773
+ ]
10774
+ }
10775
+ }, createLabelFeature(labelPoint, "E", labelRotationAlong(straightDirection), options)]
10776
+ };
10777
+ }
10778
+ const ENVELOPMENT = defineControlMeasure({
10779
+ metadata: ENVELOPMENT_METADATA,
10780
+ generator: createEnvelopment,
10781
+ defaultOptions: DEFAULT_ENVELOPMENT_OPTIONS,
10782
+ rule: line31DrawRule,
10783
+ previewSample: {
10784
+ controlPoints: [
10785
+ [-.9, -.25],
10786
+ [-.15, -.25],
10787
+ [.85, -.25],
10788
+ [.35, .45]
10789
+ ],
10790
+ options: { labelSize: 100 }
10791
+ }
10792
+ });
10793
+ //#endregion
10794
+ //#region src/generators/cm34-mission-tasks/infiltration.ts
10795
+ const DEFAULT_INFILTRATION_OPTIONS = {
10796
+ arrowheadLengthRatio: .1,
10797
+ arrowheadWidthRatio: .1,
10798
+ labelPosition: .6,
10799
+ labelGapRatio: .24,
10800
+ resolution: 64
10801
+ };
10802
+ const INFILTRATION_METADATA = {
10803
+ id: "infiltration",
10804
+ name: "Infiltration",
10805
+ description: "Moves forces through or into an area occupied by enemy forces without detection.",
10806
+ entity: "Mission Tasks",
10807
+ entityType: "Infiltration",
10808
+ value: "343800",
10809
+ minCoordinates: 3,
10810
+ maxCoordinates: 3,
10811
+ geometry: "line",
10812
+ geometryTypes: ["MultiLineString", "Point"],
10813
+ paints: {
10814
+ stroke: true,
10815
+ fill: "none",
10816
+ text: true
10817
+ },
10818
+ drawRule: "Line32",
10819
+ capturesLabelSize: true,
10820
+ params: [
10821
+ {
10822
+ key: "arrowheadLengthRatio",
10823
+ presentationTier: "advanced",
10824
+ label: "Arrowhead length",
10825
+ description: "Arrowhead length as a ratio of the Point 1–Point 3 span",
10826
+ type: "number",
10827
+ min: .01,
10828
+ max: .5,
10829
+ step: .01
10830
+ },
10831
+ {
10832
+ key: "arrowheadWidthRatio",
10833
+ presentationTier: "advanced",
10834
+ label: "Arrowhead width",
10835
+ description: "Arrowhead width as a ratio of the Point 1–Point 3 span",
10836
+ type: "number",
10837
+ min: .01,
10838
+ max: .5,
10839
+ step: .01
10840
+ },
10841
+ {
10842
+ key: "labelPosition",
10843
+ presentationTier: "advanced",
10844
+ label: "Label position",
10845
+ description: "Position of the IN label along the initial straight portion",
10846
+ type: "number",
10847
+ min: 0,
10848
+ max: 1,
10849
+ step: .01
10850
+ },
10851
+ {
10852
+ key: "labelGapRatio",
10853
+ presentationTier: "advanced",
10854
+ label: "Label gap",
10855
+ description: "Size of the initial-line cutout holding the IN label",
10856
+ type: "number",
10857
+ min: 0,
10858
+ max: 1,
10859
+ step: .01
10860
+ },
10861
+ ...ARC_RESOLUTION_PARAMS
10862
+ ]
10863
+ };
10864
+ const CENTERED_BEND_SINE = 2 * .1;
10865
+ const CENTERED_BEND_COSINE = Math.sqrt(1 - CENTERED_BEND_SINE * CENTERED_BEND_SINE);
10866
+ /**
10867
+ * Resolves the common tangent direction of the two Line32 quarter-circles.
10868
+ * For the doctrinal construction, PT. 1 and PT. 3 lie on parallel tangents on
10869
+ * opposite sides of PT. 2. Their midpoint offset from PT. 2 therefore lies on
10870
+ * that tangent axis. For a derived, centered PT. 2 the axis is mathematically
10871
+ * underdetermined, so a proportional doctrinal bend resolves it.
10872
+ */
10873
+ function tangentDirection(p1, p2, p3) {
10874
+ const axis = vecSub(vecScale(p2, 2), vecAdd(p1, p3));
10875
+ const span = vecSub(p3, p1);
10876
+ const spanLength = vecMag(span);
10877
+ let direction;
10878
+ if (vecMag(axis) < spanLength * .02) {
10879
+ const chord = vecNorm(span);
10880
+ direction = [chord[0] * CENTERED_BEND_COSINE - chord[1] * CENTERED_BEND_SINE, chord[0] * CENTERED_BEND_SINE + chord[1] * CENTERED_BEND_COSINE];
10881
+ } else direction = vecNorm(axis);
10882
+ if (vecDot(direction, span) < 0) direction = vecScale(direction, -1);
10883
+ return direction;
10884
+ }
10885
+ /** Generates the Infiltration mission task symbol (343800). */
10886
+ function createInfiltration(coordinates, options = {}) {
10887
+ const [c1, c2, c3] = coordinates;
10888
+ const p1 = project(c1[0], c1[1]);
10889
+ const p2 = project(c2[0], c2[1]);
10890
+ const p3 = project(c3[0], c3[1]);
10891
+ const spanLength = vecMag(vecSub(p3, p1));
10892
+ if (spanLength < 1e-6) return {
10893
+ type: "FeatureCollection",
10894
+ features: []
10895
+ };
10896
+ const along = tangentDirection(p1, p2, p3);
10897
+ const normal = [-along[1], along[0]];
10898
+ const signedRadius = vecDot(vecSub(p1, p2), normal);
10899
+ const radius = Math.abs(signedRadius);
10900
+ if (radius < 1e-6) return {
10901
+ type: "FeatureCollection",
10902
+ features: []
10903
+ };
10904
+ const sideRadius = vecScale(normal, (signedRadius < 0 ? -1 : 1) * radius);
10905
+ const alongRadius = vecScale(along, radius);
10906
+ const firstCenter = vecSub(p2, alongRadius);
10907
+ const arcStart = vecAdd(firstCenter, sideRadius);
10908
+ const secondCenter = vecAdd(p2, alongRadius);
10909
+ const arcEnd = vecSub(secondCenter, sideRadius);
10910
+ const straight = vecSub(arcStart, p1);
10911
+ const straightLength = vecMag(straight);
10912
+ if (straightLength < 1e-6) return {
10913
+ type: "FeatureCollection",
10914
+ features: []
10915
+ };
10916
+ const arcResolution = normalizeArcResolution(options.resolution) / 2;
10917
+ const firstArc = sampleProjectedArc(firstCenter, sideRadius, alongRadius, 0, Math.PI / 2, arcResolution);
10918
+ const secondArc = sampleProjectedArc(secondCenter, vecScale(alongRadius, -1), vecScale(sideRadius, -1), 0, Math.PI / 2, arcResolution);
10919
+ const labelPoint = vecAdd(p1, vecScale(straight, clamp01(options.labelPosition ?? DEFAULT_INFILTRATION_OPTIONS.labelPosition)));
10920
+ const gapHalf = straightLength * clamp01(options.labelGapRatio ?? DEFAULT_INFILTRATION_OPTIONS.labelGapRatio) / 2;
10921
+ const gapStart = vecAdd(labelPoint, vecScale(along, -gapHalf));
10922
+ const gapEnd = vecAdd(labelPoint, vecScale(along, gapHalf));
10923
+ const arrowhead = createArrowHead$1(p3, along, spanLength * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadLengthRatio), spanLength * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadWidthRatio));
10924
+ return {
10925
+ type: "FeatureCollection",
10926
+ features: [{
10927
+ type: "Feature",
10928
+ properties: { part: "infiltration" },
10929
+ geometry: {
10930
+ type: "MultiLineString",
10931
+ coordinates: [
10932
+ [c1, unproject(gapStart[0], gapStart[1])],
10933
+ [unproject(gapEnd[0], gapEnd[1]), unproject(arcStart[0], arcStart[1])],
10934
+ firstArc,
10935
+ secondArc,
10936
+ [unproject(arcEnd[0], arcEnd[1]), c3],
10937
+ arrowhead
10938
+ ]
10939
+ }
10940
+ }, createLabelFeature(labelPoint, "IN", labelRotationAlong(along), options)]
10941
+ };
10942
+ }
10943
+ const INFILTRATION = defineControlMeasure({
10944
+ metadata: INFILTRATION_METADATA,
10945
+ generator: createInfiltration,
10946
+ defaultOptions: DEFAULT_INFILTRATION_OPTIONS,
10947
+ rule: line32DrawRule,
10948
+ previewSample: {
10949
+ controlPoints: [
10950
+ [-.9, .25],
10951
+ [-.05, 0],
10952
+ [.9, -.25]
10953
+ ],
10954
+ options: { labelSize: 100 }
10955
+ }
10956
+ });
10957
+ //#endregion
10384
10958
  //#region src/generators/cm34-mission-tasks/guard.ts
10385
10959
  const DEFAULT_GUARD_OPTIONS = DEFAULT_SECURITY_TASK_OPTIONS;
10386
10960
  const GUARD_METADATA = {
@@ -12742,7 +13316,8 @@ const FORTIFIED_AREA = defineControlMeasure({
12742
13316
  //#endregion
12743
13317
  //#region src/generators/cm15-maneuver-areas/maneuver-arrow-task-shared.ts
12744
13318
  const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
12745
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
13319
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
13320
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
12746
13321
  smooth: false,
12747
13322
  smoothResolution: 5,
12748
13323
  crossbarLengthRatio: 1.1,
@@ -12751,10 +13326,14 @@ const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
12751
13326
  labelPadding: 0
12752
13327
  };
12753
13328
  function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
12754
- const resolved = {
13329
+ const merged = {
12755
13330
  ...DEFAULT_MANEUVER_ARROW_TASK_OPTIONS,
12756
13331
  ...options
12757
13332
  };
13333
+ const resolved = {
13334
+ ...merged,
13335
+ rearWidthRatio: resolveRearWidthRatio(options, merged.shaftWidthRatio)
13336
+ };
12758
13337
  const { geometry } = processAttackGeometry(coordinates, resolved);
12759
13338
  if (!geometry) return {
12760
13339
  type: "FeatureCollection",
@@ -12775,17 +13354,22 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
12775
13354
  };
12776
13355
  const tipVector = vecSub(geometry.ptTip, geometry.ptBase);
12777
13356
  const tipDirection = vecNorm(tipVector);
12778
- const crossbarLength = vecMag(vecSub(geometry.headRing[0], geometry.headRing[2])) * Math.max(1, resolved.crossbarLengthRatio);
13357
+ const headWidth = vecMag(vecSub(geometry.headRing[0], geometry.headRing[2]));
12779
13358
  let crossbarCenter;
12780
13359
  let crossbarAlong;
13360
+ let crossbarBaseWidth = headWidth;
12781
13361
  if (config.crossbarAt === "tip") {
12782
13362
  crossbarCenter = geometry.ptTip;
12783
13363
  crossbarAlong = tipDirection;
12784
13364
  } else {
12785
- const crossbarFrame = pointAlongPolyline(segments, totalLength, Number.isFinite(config.crossbarAt.shaftPosition) ? clamp01(config.crossbarAt.shaftPosition) : 0);
13365
+ const shaftPosition = Number.isFinite(config.crossbarAt.shaftPosition) ? clamp01(config.crossbarAt.shaftPosition) : 0;
13366
+ const crossbarFrame = pointAlongPolyline(segments, totalLength, shaftPosition);
12786
13367
  crossbarCenter = crossbarFrame.point;
12787
13368
  crossbarAlong = crossbarFrame.along;
13369
+ const rearWidth = vecMag(vecSub(geometry.shaftLeft[0], geometry.shaftRight[0]));
13370
+ crossbarBaseWidth = rearWidth + (vecMag(vecSub(geometry.shaftLeft.at(-1), geometry.shaftRight.at(-1))) - rearWidth) * shaftPosition;
12788
13371
  }
13372
+ const crossbarLength = crossbarBaseWidth * Math.max(1, resolved.crossbarLengthRatio);
12789
13373
  const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
12790
13374
  const crossbar = [vecAdd(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2)), vecSub(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2))];
12791
13375
  const features = [{
@@ -12925,6 +13509,7 @@ const FRONTAL_ATTACK = defineControlMeasure({
12925
13509
  generator: createFrontalAttack,
12926
13510
  defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
12927
13511
  rule: axis1DrawRule,
13512
+ optionHandles: variableWidthAttackOptionHandles,
12928
13513
  previewSample: {
12929
13514
  controlPoints: [
12930
13515
  [1, 0],
@@ -13962,7 +14547,8 @@ const NO_FIRE_AREA_IRREGULAR = defineControlMeasure({
13962
14547
  //#endregion
13963
14548
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
13964
14549
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
13965
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
14550
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
14551
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
13966
14552
  smooth: false,
13967
14553
  smoothResolution: 5
13968
14554
  };
@@ -13990,7 +14576,7 @@ const MAIN_ATTACK_METADATA = {
13990
14576
  *
13991
14577
  * The symbol consists of a single MultiLineString feature containing:
13992
14578
  * 1. **Arrowhead**: A line forming a "chevron" or "roof" shape.
13993
- * 2. **Shaft**: Two parallel lines behind the arrow.
14579
+ * 2. **Shaft**: Two boundary lines behind the arrow, optionally flared toward the rear.
13994
14580
  *
13995
14581
  * The shaft is calculated to terminate exactly where it touches the inner walls
13996
14582
  * of the arrowhead, creating a seamless connection.
@@ -14029,7 +14615,8 @@ const MAIN_ATTACK = defineControlMeasure({
14029
14615
  metadata: MAIN_ATTACK_METADATA,
14030
14616
  generator: createMainAttack,
14031
14617
  defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
14032
- rule: axis1DrawRule
14618
+ rule: axis1DrawRule,
14619
+ optionHandles: variableWidthAttackOptionHandles
14033
14620
  });
14034
14621
  //#endregion
14035
14622
  //#region src/generators/cm27-protection-areas/mine-types.ts
@@ -16226,6 +16813,8 @@ const DEFINITIONS = {
16226
16813
  delay: DELAY,
16227
16814
  disengage: DISENGAGE,
16228
16815
  "disrupt-mission-task": DISRUPT_MISSION_TASK,
16816
+ envelopment: ENVELOPMENT,
16817
+ infiltration: INFILTRATION,
16229
16818
  guard: GUARD,
16230
16819
  isolate: ISOLATE,
16231
16820
  penetrate: PENETRATE,
@@ -16246,6 +16835,7 @@ const DEFINITIONS = {
16246
16835
  generator: createTurningMovement,
16247
16836
  defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
16248
16837
  rule: axis1DrawRule,
16838
+ optionHandles: variableWidthAttackOptionHandles,
16249
16839
  previewSample: {
16250
16840
  controlPoints: [
16251
16841
  [1, 0],