@orbat-mapper/control-measures 0.2.0-alpha.11 → 0.2.0-alpha.13

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.
@@ -510,7 +510,7 @@ function samePosition(a, b) {
510
510
  }
511
511
  //#endregion
512
512
  //#region src/draw-rules/area1.ts
513
- function derive$8(points) {
513
+ function derive$9(points) {
514
514
  return clonePositions(points);
515
515
  }
516
516
  /**
@@ -530,9 +530,9 @@ const area1DrawRule = {
530
530
  id: "area1",
531
531
  minimumUserPoints: 3,
532
532
  closedRing: true,
533
- derive: derive$8,
533
+ derive: derive$9,
534
534
  transform(event) {
535
- return derive$8(event.next);
535
+ return derive$9(event.next);
536
536
  }
537
537
  };
538
538
  //#endregion
@@ -558,14 +558,14 @@ const disruptDrawRule = createMidpointPerpendicularDrawRule({
558
558
  * that fixes both the radius and the bearing of the symbol's opening. Both
559
559
  * points are user-clicked, so the canonical array is just the (clamped) input.
560
560
  */
561
- function derive$7(points) {
561
+ function derive$8(points) {
562
562
  return clonePositions(points.slice(0, 2));
563
563
  }
564
564
  const centerRadiusDrawRule = {
565
565
  id: "area15:center-radius",
566
566
  minimumUserPoints: 2,
567
567
  canonicalPointCount: 2,
568
- derive: derive$7,
568
+ derive: derive$8,
569
569
  transform(event) {
570
570
  const { previous, next, activePointIndex } = event;
571
571
  if (activePointIndex === 0 && previous.length >= 2 && next.length >= 2) {
@@ -574,7 +574,7 @@ const centerRadiusDrawRule = {
574
574
  const origin = previous[1];
575
575
  return [clonePosition(next[0]), [origin[0] + dx, origin[1] + dy]];
576
576
  }
577
- return derive$7(next);
577
+ return derive$8(next);
578
578
  }
579
579
  };
580
580
  /** Backward-compatible doctrinal name for Area15's shared center-radius rule. */
@@ -619,20 +619,20 @@ const ambushDrawRule = createMidpointPerpendicularDrawRule({
619
619
  });
620
620
  //#endregion
621
621
  //#region src/draw-rules/line1.ts
622
- function derive$6(points) {
622
+ function derive$7(points) {
623
623
  return clonePositions(points);
624
624
  }
625
625
  const line1DrawRule = {
626
626
  id: "line1",
627
627
  minimumUserPoints: 2,
628
- derive: derive$6,
628
+ derive: derive$7,
629
629
  transform(event) {
630
- return derive$6(event.next);
630
+ return derive$7(event.next);
631
631
  }
632
632
  };
633
633
  //#endregion
634
634
  //#region src/draw-rules/line3.ts
635
- function derive$5(points) {
635
+ function derive$6(points) {
636
636
  return clonePositions(points);
637
637
  }
638
638
  /**
@@ -647,23 +647,23 @@ const line3DrawRule = {
647
647
  id: "line3",
648
648
  minimumUserPoints: 3,
649
649
  canonicalPointCount: 3,
650
- derive: derive$5,
650
+ derive: derive$6,
651
651
  transform(event) {
652
- return derive$5(event.next);
652
+ return derive$6(event.next);
653
653
  }
654
654
  };
655
655
  //#endregion
656
656
  //#region src/draw-rules/line9.ts
657
- function derive$4(points) {
657
+ function derive$5(points) {
658
658
  return clonePositions(points);
659
659
  }
660
660
  const line9DrawRule = {
661
661
  id: "line9",
662
662
  minimumUserPoints: 2,
663
663
  canonicalPointCount: 2,
664
- derive: derive$4,
664
+ derive: derive$5,
665
665
  transform(event) {
666
- return derive$4(event.next);
666
+ return derive$5(event.next);
667
667
  }
668
668
  };
669
669
  //#endregion
@@ -768,6 +768,52 @@ const line24DrawRule = createMidpointPerpendicularDrawRule({
768
768
  minimumPreviewPoints: 2
769
769
  });
770
770
  //#endregion
771
+ //#region src/draw-rules/line26.ts
772
+ function derive$4(points) {
773
+ if (points.length === 2) {
774
+ const [p1, p2] = points;
775
+ const dx = p2[0] - p1[0];
776
+ const dy = p2[1] - p1[1];
777
+ const p3 = [p2[0] + dx * .5, p2[1] + dy * .5];
778
+ return clonePositions([
779
+ p1,
780
+ p2,
781
+ p3,
782
+ [p3[0] + dx, p3[1] + dy]
783
+ ]);
784
+ }
785
+ if (points.length === 3) {
786
+ const [p1, p2, p3] = points;
787
+ return clonePositions([
788
+ p1,
789
+ p2,
790
+ p3,
791
+ [p3[0] + p2[0] - p1[0], p3[1] + p2[1] - p1[1]]
792
+ ]);
793
+ }
794
+ return clonePositions(points);
795
+ }
796
+ /**
797
+ * Line26 anchor draw rule for the Cover, Guard, and Screen security tasks.
798
+ *
799
+ * PT. 1/PT. 4 are the outward arrow tips and PT. 2/PT. 3 bound the opening
800
+ * reserved for the supported tactical symbol. All four anchors are free: the
801
+ * two sides may independently touch the symbols that define the task limits.
802
+ * During drawing, the cursor after PT. 1 supplies a provisional PT. 2 and the
803
+ * rule mirrors that first leg across a temporary center opening, so the full
804
+ * symbol is visible before PT. 2 is committed.
805
+ */
806
+ const line26DrawRule = {
807
+ id: "line26",
808
+ minimumUserPoints: 4,
809
+ minimumPreviewPoints: 2,
810
+ canonicalPointCount: 4,
811
+ derive: derive$4,
812
+ transform(event) {
813
+ return derive$4(event.next);
814
+ }
815
+ };
816
+ //#endregion
771
817
  //#region src/draw-rules/area8.ts
772
818
  function derive$3(points) {
773
819
  if (points.length < 2) return clonePositions(points);
@@ -1761,6 +1807,27 @@ function buildGappedLine(verts, gaps) {
1761
1807
  return parts;
1762
1808
  }
1763
1809
  //#endregion
1810
+ //#region src/internal/angle-utils.ts
1811
+ /**
1812
+ * Wraps an angle (radians) into the half-open range (-π, π].
1813
+ */
1814
+ function normalizeRadians(angle) {
1815
+ let normalized = angle % (Math.PI * 2);
1816
+ if (normalized > Math.PI) normalized -= Math.PI * 2;
1817
+ if (normalized <= -Math.PI) normalized += Math.PI * 2;
1818
+ return normalized;
1819
+ }
1820
+ /**
1821
+ * Flips a text rotation by π when it would otherwise read upside-down, keeping
1822
+ * the baseline pointing left-to-right (within ±90° of horizontal).
1823
+ */
1824
+ function keepTextLeftToRight(rotation) {
1825
+ const normalized = normalizeRadians(rotation);
1826
+ if (normalized > Math.PI / 2) return normalized - Math.PI;
1827
+ if (normalized < -Math.PI / 2) return normalized + Math.PI;
1828
+ return normalized;
1829
+ }
1830
+ //#endregion
1764
1831
  //#region src/internal/sketch.ts
1765
1832
  /**
1766
1833
  * Projects `coordinates` and derives the arrow's tip, heading and total length.
@@ -1826,6 +1893,46 @@ const SMOOTH_LINE_PARAMS = [{
1826
1893
  function smoothLineVerts(verts, options, defaultResolution) {
1827
1894
  return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1828
1895
  }
1896
+ /**
1897
+ * Resamples a path into equal arc-length segments.
1898
+ *
1899
+ * This is useful for a repeated motif (such as a fortified tooth or FLOT
1900
+ * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1901
+ * parameter, not distance, so using them directly as motif boundaries causes
1902
+ * the motifs to bunch up where the curve's samples are closer together.
1903
+ */
1904
+ function evenlySpacePath(points, targetSegmentLength) {
1905
+ if (points.length < 2) return points;
1906
+ const segmentLengths = [];
1907
+ let totalLength = 0;
1908
+ for (let i = 0; i < points.length - 1; i++) {
1909
+ const length = vecMag(vecSub(points[i + 1], points[i]));
1910
+ segmentLengths.push(length);
1911
+ totalLength += length;
1912
+ }
1913
+ if (totalLength < 1e-6) return [points[0]];
1914
+ const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1915
+ const segmentLength = totalLength / segmentCount;
1916
+ const out = [points[0]];
1917
+ let sourceIndex = 0;
1918
+ let sourceStart = points[0];
1919
+ let consumed = 0;
1920
+ for (let segment = 1; segment < segmentCount; segment++) {
1921
+ const target = segment * segmentLength;
1922
+ while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1923
+ consumed += segmentLengths[sourceIndex];
1924
+ sourceIndex++;
1925
+ sourceStart = points[sourceIndex];
1926
+ }
1927
+ const length = segmentLengths[sourceIndex];
1928
+ if (length < 1e-6) continue;
1929
+ const t = (target - consumed) / length;
1930
+ const sourceEnd = points[sourceIndex + 1];
1931
+ out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
1932
+ }
1933
+ out.push(points[points.length - 1]);
1934
+ return out;
1935
+ }
1829
1936
  /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1830
1937
  function catmullRomAt(p0, p1, p2, p3, t) {
1831
1938
  const t2 = t * t;
@@ -1876,177 +1983,6 @@ function closedCatmullRom(points, samples) {
1876
1983
  return out;
1877
1984
  }
1878
1985
  //#endregion
1879
- //#region src/internal/area-echelon.ts
1880
- /**
1881
- * Twice the ring's signed area (shoelace). Positive when the ring winds
1882
- * counter-clockwise in projected (y-up) coordinates. Exported for reuse by
1883
- * `area-labels.ts`'s polygon-centroid label placement, so the area centroid
1884
- * shares this one derivation instead of re-deriving it.
1885
- */
1886
- function ringSignedArea2(verts) {
1887
- let area2 = 0;
1888
- for (let i = 0; i < verts.length - 1; i++) {
1889
- const a = verts[i];
1890
- const b = verts[i + 1];
1891
- area2 += a[0] * b[1] - b[0] * a[1];
1892
- }
1893
- return area2;
1894
- }
1895
- /**
1896
- * Projects `positions` and returns a closed ring (first == last), optionally
1897
- * smoothed through the control points with a closed centripetal Catmull-Rom
1898
- * spline. A trailing vertex that duplicates the first (already-closed input)
1899
- * is dropped before closing/smoothing.
1900
- */
1901
- function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
1902
- const projected = positions.map((p) => project(p[0], p[1]));
1903
- const ringPts = projected.length > 1 && Math.abs(projected[0][0] - projected[projected.length - 1][0]) < 1e-6 && Math.abs(projected[0][1] - projected[projected.length - 1][1]) < 1e-6 ? projected.slice(0, -1) : projected;
1904
- return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
1905
- }
1906
- /**
1907
- * Walks a closed ring into per-segment metadata (unit direction, outward
1908
- * normal, cumulative arc length) plus the total perimeter and the arc-length
1909
- * anchor of the bottom edge — the segment whose midpoint sits lowest (min
1910
- * projected y), the side opposite the front.
1911
- */
1912
- function buildAreaPerimeter(verts) {
1913
- const ccw = ringSignedArea2(verts) > 0;
1914
- const segs = [];
1915
- let totalLength = 0;
1916
- let anchorTarget = 0;
1917
- let lowest = Infinity;
1918
- for (let i = 0; i < verts.length - 1; i++) {
1919
- const a = verts[i];
1920
- const b = verts[i + 1];
1921
- const edge = vecSub(b, a);
1922
- const length = vecMag(edge);
1923
- if (length < 1e-6) continue;
1924
- const along = vecNorm(edge);
1925
- const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1926
- const perp = ccw ? [along[1], -along[0]] : [-along[1], along[0]];
1927
- segs.push({
1928
- start: a,
1929
- along,
1930
- perp,
1931
- length,
1932
- arcStart: totalLength
1933
- });
1934
- if (mid[1] < lowest) {
1935
- lowest = mid[1];
1936
- anchorTarget = totalLength + length / 2;
1937
- }
1938
- totalLength += length;
1939
- }
1940
- return {
1941
- verts,
1942
- segs,
1943
- totalLength,
1944
- anchorTarget
1945
- };
1946
- }
1947
- /** Resolves the arc-length `target` to a point and the segment frame it lands on. */
1948
- function pointOnRing(segs, target) {
1949
- let seg = segs[segs.length - 1];
1950
- for (const candidate of segs) if (target < candidate.arcStart + candidate.length) {
1951
- seg = candidate;
1952
- break;
1953
- }
1954
- return {
1955
- point: vecAdd(seg.start, vecScale(seg.along, target - seg.arcStart)),
1956
- seg
1957
- };
1958
- }
1959
- /**
1960
- * Effective echelon glyph height in meters. Screen-anchored when a pixel size
1961
- * is supplied with a live resolution; ground-anchored (meters) otherwise.
1962
- * Clamped strictly positive.
1963
- */
1964
- function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
1965
- let h = echelonSize;
1966
- if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
1967
- return Math.max(EPSILON, h);
1968
- }
1969
- /** Wraps `x` into `[0, 1)`, treating `x` as a fraction (matches the arc-length wrap below). */
1970
- function wrap01(x) {
1971
- return (x % 1 + 1) % 1;
1972
- }
1973
- /**
1974
- * Places the Field B echelon glyph on the perimeter and computes its masking
1975
- * gap, anchored by default to the bottom edge and slid by `echelonPosition`
1976
- * (a signed fraction of the perimeter, wrapping at the seam). When
1977
- * `echelonAnchor` is supplied, it replaces the bottom-edge anchor with an
1978
- * absolute perimeter fraction (measured from the ring seam) — see
1979
- * `BattlePositionOptions.echelonAnchor` for why (ADR-0023 rotate-freeze).
1980
- * Returns empty geometry when the echelon resolves to none or the ring is
1981
- * degenerate.
1982
- */
1983
- function placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, echelonAnchor) {
1984
- const { segs, totalLength, anchorTarget } = perimeter;
1985
- const spec = resolveEchelon(echelon);
1986
- if (!spec || segs.length === 0 || totalLength < 1e-6) return {
1987
- strokes: [],
1988
- fills: [],
1989
- gaps: []
1990
- };
1991
- const target = (((echelonAnchor !== void 0 ? wrap01(echelonAnchor) * totalLength : anchorTarget) + echelonPosition * totalLength) % totalLength + totalLength) % totalLength;
1992
- const { point, seg } = pointOnRing(segs, target);
1993
- const { strokes, fills, width } = buildEchelonGlyph(point, seg.along, seg.perp, h, spec);
1994
- const gaps = [];
1995
- const halfGap = width / 2 + Math.max(0, echelonPadding) * h;
1996
- pushWrappedGap(gaps, target - halfGap, target + halfGap, totalLength);
1997
- return {
1998
- strokes,
1999
- fills,
2000
- gaps
2001
- };
2002
- }
2003
- /**
2004
- * Factory for the `freezeOrientation` hook (ADR-0023) shared by every
2005
- * echelon-carrying closed-area measure (Battle Position, Strong Point, Base
2006
- * Camp, Guerrilla Base): pins the echelon anchor to its current bottom-edge
2007
- * fraction so a subsequent rigid rotation of `controlPoints` (e.g. the
2008
- * transform box's rotate grip) does not re-derive a different "bottom edge"
2009
- * against the rotated geometry. `defaults` supplies the measure's own
2010
- * `echelon`/`smooth`/`smoothResolution` defaults, since each measure's
2011
- * `DEFAULT_*_OPTIONS` differ (e.g. Battle Position's `echelon: "battalion"`
2012
- * vs. Base Camp/Guerrilla Base's `echelon: "none"`).
2013
- *
2014
- * The returned hook returns `undefined` — nothing to freeze — when:
2015
- * - `echelonAnchor` is already set (previously frozen or user-authored), or
2016
- * - the echelon resolves to none (nothing visible to pin), or
2017
- * - the ring is degenerate (no segments, or a near-zero perimeter).
2018
- */
2019
- function makeFreezeEchelonAnchor(defaults) {
2020
- return function freezeEchelonAnchor(controlPoints, options) {
2021
- if (options.echelonAnchor !== void 0) return void 0;
2022
- if (!resolveEchelon(options.echelon ?? defaults.echelon)) return void 0;
2023
- const perimeter = buildAreaPerimeter(buildClosedRing(controlPoints, options.smooth ?? defaults.smooth, options.smoothResolution ?? defaults.smoothResolution, defaults.smoothResolution));
2024
- if (perimeter.segs.length === 0 || perimeter.totalLength < 1e-6) return void 0;
2025
- return { echelonAnchor: perimeter.anchorTarget / perimeter.totalLength };
2026
- };
2027
- }
2028
- //#endregion
2029
- //#region src/internal/angle-utils.ts
2030
- /**
2031
- * Wraps an angle (radians) into the half-open range (-π, π].
2032
- */
2033
- function normalizeRadians(angle) {
2034
- let normalized = angle % (Math.PI * 2);
2035
- if (normalized > Math.PI) normalized -= Math.PI * 2;
2036
- if (normalized <= -Math.PI) normalized += Math.PI * 2;
2037
- return normalized;
2038
- }
2039
- /**
2040
- * Flips a text rotation by π when it would otherwise read upside-down, keeping
2041
- * the baseline pointing left-to-right (within ±90° of horizontal).
2042
- */
2043
- function keepTextLeftToRight(rotation) {
2044
- const normalized = normalizeRadians(rotation);
2045
- if (normalized > Math.PI / 2) return normalized - Math.PI;
2046
- if (normalized < -Math.PI / 2) return normalized + Math.PI;
2047
- return normalized;
2048
- }
2049
- //#endregion
2050
1986
  //#region src/internal/line-labels.ts
2051
1987
  /**
2052
1988
  * Stored label rotation for text running along projected direction `dir`.
@@ -2067,13 +2003,13 @@ function labelRotationAlong(dir) {
2067
2003
  */
2068
2004
  function pushUnitLabels(out, frame, units) {
2069
2005
  const { center, perp, offset, rotation, labelSize } = frame;
2070
- for (const { side, designator, country, textAnchor } of units) {
2006
+ for (const { side, designator, country, textAnchor, amplifierField } of units) {
2071
2007
  let text;
2072
2008
  if (designator.length > 0 && country.length > 0) text = `${designator} (${country})`;
2073
2009
  else if (designator.length > 0) text = designator;
2074
2010
  else if (country.length > 0) text = `(${country})`;
2075
2011
  else continue;
2076
- pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor);
2012
+ pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor, amplifierField);
2077
2013
  }
2078
2014
  }
2079
2015
  /**
@@ -2199,7 +2135,7 @@ function labelSizeProps(options) {
2199
2135
  return {};
2200
2136
  }
2201
2137
  /** Pushes a `part: "label"` Point feature at projected `point`. */
2202
- function pushLabel(out, point, text, rotation, sizeProps, textAnchor) {
2138
+ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField) {
2203
2139
  out.push({
2204
2140
  type: "Feature",
2205
2141
  properties: {
@@ -2207,6 +2143,7 @@ function pushLabel(out, point, text, rotation, sizeProps, textAnchor) {
2207
2143
  text,
2208
2144
  rotation,
2209
2145
  ...textAnchor ? { textAnchor } : {},
2146
+ ...amplifierField ? { amplifierField } : {},
2210
2147
  ...sizeProps
2211
2148
  },
2212
2149
  geometry: {
@@ -2349,16 +2286,662 @@ function fixedLabelLineFeatures(positions, options, textForEnd) {
2349
2286
  };
2350
2287
  }
2351
2288
  //#endregion
2352
- //#region src/internal/area-labels.ts
2353
- /** Stored label rotation for horizontal (screen-upright) area text — adapters
2354
- * render feature rotation as `π − rotation`, so `π` renders as `0`. Exported
2355
- * for reuse by measures that place a horizontal label outside the area's
2356
- * boundary (e.g. Airfield Zone's Field H). */
2357
- const HORIZONTAL_LABEL_ROTATION = Math.PI;
2358
- /** Row pitch, as a ratio of the resolved label height, between stacked
2359
- * name/H/W-W1 rows (see {@link pushAreaLabels}). */
2360
- const ROW_PITCH_RATIO = 1.2;
2361
- /** Clearance, as a ratio of the resolved label height, added on top of each
2289
+ //#region src/generators/cm14-maneuver-lines/direction-of-attack-shared.ts
2290
+ const DEFAULT_DIRECTION_ARROWHEAD_LENGTH_RATIO = .12;
2291
+ const DEFAULT_DIRECTION_ARROWHEAD_WIDTH_RATIO = 1.15;
2292
+ const DEFAULT_DIRECTION_LABEL_POSITION = .5;
2293
+ /** Approximate sans-serif character width/height ratio used for the T knockout. */
2294
+ const LABEL_CHAR_ASPECT_RATIO = .6;
2295
+ /** Clear space on each side of T, as a ratio of the resolved label height. */
2296
+ const LABEL_GAP_CLEARANCE_RATIO = .3;
2297
+ function nonNegative$1(value, fallback) {
2298
+ return Number.isFinite(value) ? Math.max(0, value) : fallback;
2299
+ }
2300
+ /** Resolves an along-line position option onto the valid tail-to-tip range. */
2301
+ function clampLinePosition(value, fallback) {
2302
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2303
+ }
2304
+ /** Arc-length knockout centered on an along-route text label. */
2305
+ function directionLabelGap(totalLength, fraction, text, options) {
2306
+ if (!text) return void 0;
2307
+ const textHeight = resolveLabelOffsetMeters(options, 1);
2308
+ const textWidth = text.length * LABEL_CHAR_ASPECT_RATIO * textHeight;
2309
+ const clearance = (LABEL_GAP_CLEARANCE_RATIO + Math.max(0, options.labelPadding ?? 0)) * textHeight;
2310
+ const halfGap = textWidth / 2 + clearance;
2311
+ const center = fraction * totalLength;
2312
+ return {
2313
+ g0: Math.max(0, center - halfGap),
2314
+ g1: Math.min(totalLength, center + halfGap)
2315
+ };
2316
+ }
2317
+ function createDirectionOfAttackLine(coordinates, options, textAmplifiers, config) {
2318
+ const verts = smoothLineVerts(coordinates.slice().reverse().map((position) => project(position[0], position[1])), options, 12);
2319
+ const { segments, totalLength } = polylineSegments(verts);
2320
+ if (segments.length === 0) return {
2321
+ type: "FeatureCollection",
2322
+ features: []
2323
+ };
2324
+ const last = segments[segments.length - 1];
2325
+ const tip = vecAdd(last.start, vecScale(last.along, last.length));
2326
+ const length = Math.min(totalLength, totalLength * nonNegative$1(options.arrowheadLengthRatio, DEFAULT_DIRECTION_ARROWHEAD_LENGTH_RATIO));
2327
+ const innerHalfWidth = length * nonNegative$1(options.arrowheadWidthRatio, DEFAULT_DIRECTION_ARROWHEAD_WIDTH_RATIO) / 2;
2328
+ const base = vecSub(tip, vecScale(last.along, length));
2329
+ const innerLeft = vecAdd(base, vecScale(last.perp, innerHalfWidth));
2330
+ const innerRight = vecSub(base, vecScale(last.perp, innerHalfWidth));
2331
+ const arrowhead = config.outlineRatio ? (() => {
2332
+ const ratio = Math.max(0, config.outlineRatio);
2333
+ const outerHalfWidth = innerHalfWidth * (1 + ratio);
2334
+ const outerLeft = vecAdd(base, vecScale(last.perp, outerHalfWidth));
2335
+ return [
2336
+ innerLeft,
2337
+ tip,
2338
+ innerRight,
2339
+ vecSub(base, vecScale(last.perp, outerHalfWidth)),
2340
+ vecAdd(tip, vecScale(last.along, length * ratio)),
2341
+ outerLeft,
2342
+ innerLeft
2343
+ ];
2344
+ })() : [
2345
+ innerLeft,
2346
+ tip,
2347
+ innerRight
2348
+ ];
2349
+ const labelPosition = clampLinePosition(options.labelPosition, DEFAULT_DIRECTION_LABEL_POSITION);
2350
+ const labelGap = directionLabelGap(totalLength, labelPosition, textAmplifiers.T, options);
2351
+ const lines = [...buildGappedLine(verts, labelGap ? [labelGap] : []), arrowhead.map((point) => unproject(point[0], point[1]))];
2352
+ const features = [{
2353
+ type: "Feature",
2354
+ properties: { part: config.part },
2355
+ geometry: {
2356
+ type: "MultiLineString",
2357
+ coordinates: lines
2358
+ }
2359
+ }];
2360
+ const frame = pointAlongPolyline(segments, totalLength, labelPosition);
2361
+ if (!frame) return {
2362
+ type: "FeatureCollection",
2363
+ features
2364
+ };
2365
+ const labels = [];
2366
+ const rotation = labelRotationAlong(frame.along);
2367
+ const sizeProps = labelSizeProps(options);
2368
+ if (textAmplifiers.T) pushLabel(labels, frame.point, textAmplifiers.T, rotation, sizeProps, void 0, "T");
2369
+ const rowOffset = resolveLabelOffsetMeters(options, 1 + Math.max(0, options.labelPadding ?? 0));
2370
+ if (textAmplifiers.W) pushLabel(labels, vecSub(frame.point, vecScale(frame.perp, rowOffset)), textAmplifiers.W, rotation, sizeProps, void 0, "W");
2371
+ if (textAmplifiers.W1) pushLabel(labels, vecSub(frame.point, vecScale(frame.perp, rowOffset * 2)), textAmplifiers.W1, rotation, sizeProps, void 0, "W1");
2372
+ features.push(...labels);
2373
+ return {
2374
+ type: "FeatureCollection",
2375
+ features
2376
+ };
2377
+ }
2378
+ const DIRECTION_OF_ATTACK_PARAMS = [
2379
+ {
2380
+ key: "arrowheadLengthRatio",
2381
+ label: "Arrowhead length",
2382
+ description: "Arrowhead length as a ratio of the route's total length",
2383
+ type: "number",
2384
+ min: .01,
2385
+ max: .5,
2386
+ step: .01
2387
+ },
2388
+ {
2389
+ key: "arrowheadWidthRatio",
2390
+ label: "Arrowhead width",
2391
+ description: "Arrowhead total width as a ratio of its length",
2392
+ type: "number",
2393
+ min: .1,
2394
+ max: 2,
2395
+ step: .05
2396
+ },
2397
+ {
2398
+ key: "labelPosition",
2399
+ label: "Label position",
2400
+ description: "Position of the T/W/W1 group along the route, from tail (0) to tip (1)",
2401
+ type: "number",
2402
+ min: 0,
2403
+ max: 1,
2404
+ step: .01
2405
+ },
2406
+ {
2407
+ key: "labelPadding",
2408
+ label: "Label padding",
2409
+ description: "Extra spacing between the route and the date-time rows, in label heights",
2410
+ type: "number",
2411
+ min: 0,
2412
+ max: 2,
2413
+ step: .05
2414
+ }
2415
+ ];
2416
+ const DIRECTION_OF_ATTACK_TEXT_AMPLIFIERS = [
2417
+ {
2418
+ key: "T",
2419
+ label: "Unique designation",
2420
+ description: "Field T — the attack route's unique designation.",
2421
+ placeholder: "MAIN",
2422
+ maxLength: 21
2423
+ },
2424
+ {
2425
+ key: "W",
2426
+ label: "Start date-time group",
2427
+ description: "Field W — the route's start date-time group.",
2428
+ placeholder: "080400ZOCT2008"
2429
+ },
2430
+ {
2431
+ key: "W1",
2432
+ label: "End date-time group",
2433
+ description: "Field W1 — the route's end date-time group.",
2434
+ placeholder: "120300ZOCT2008"
2435
+ }
2436
+ ];
2437
+ const DIRECTION_OF_ATTACK_PREVIEW_TEXT = {
2438
+ W: "080400ZOCT2008",
2439
+ W1: "120300ZOCT2008"
2440
+ };
2441
+ //#endregion
2442
+ //#region src/generators/cm14-maneuver-lines/direction-of-attack-aviation.ts
2443
+ const DEFAULT_ARROWHEAD_LENGTH_RATIO$1 = .12;
2444
+ const DEFAULT_ARROWHEAD_WIDTH_RATIO$1 = 1.15;
2445
+ const DEFAULT_BOW_TIE_LENGTH_RATIO = .1;
2446
+ const DEFAULT_BOW_TIE_WIDTH_RATIO = .5;
2447
+ const DEFAULT_BOW_TIE_POSITION = 1 / 3;
2448
+ const DEFAULT_LABEL_POSITION = 2 / 3;
2449
+ const DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS = {
2450
+ arrowheadLengthRatio: DEFAULT_ARROWHEAD_LENGTH_RATIO$1,
2451
+ arrowheadWidthRatio: DEFAULT_ARROWHEAD_WIDTH_RATIO$1,
2452
+ bowTieLengthRatio: DEFAULT_BOW_TIE_LENGTH_RATIO,
2453
+ bowTieWidthRatio: DEFAULT_BOW_TIE_WIDTH_RATIO,
2454
+ bowTiePosition: DEFAULT_BOW_TIE_POSITION,
2455
+ labelPosition: DEFAULT_LABEL_POSITION,
2456
+ labelPadding: 0,
2457
+ smooth: false,
2458
+ smoothResolution: 12
2459
+ };
2460
+ const DIRECTION_OF_ATTACK_AVIATION_METADATA = {
2461
+ id: "direction-of-attack-aviation",
2462
+ name: "Direction of Attack — Airborne/Aviation",
2463
+ description: "The direction or route of an airborne or aviation attack.",
2464
+ entity: "Maneuver Lines",
2465
+ entityType: "Direction of Attack",
2466
+ entitySubtype: "Airborne/Aviation",
2467
+ value: "140601",
2468
+ minCoordinates: 2,
2469
+ geometry: "line",
2470
+ geometryTypes: ["MultiLineString", "Point"],
2471
+ drawRule: "Line1",
2472
+ capturesLabelSize: true,
2473
+ params: [
2474
+ ...SMOOTH_LINE_PARAMS,
2475
+ {
2476
+ key: "arrowheadLengthRatio",
2477
+ label: "Arrowhead length",
2478
+ description: "Arrowhead length as a ratio of the route's total length",
2479
+ type: "number",
2480
+ min: .01,
2481
+ max: .5,
2482
+ step: .01
2483
+ },
2484
+ {
2485
+ key: "arrowheadWidthRatio",
2486
+ label: "Arrowhead width",
2487
+ description: "Arrowhead total width as a ratio of its length",
2488
+ type: "number",
2489
+ min: .1,
2490
+ max: 2,
2491
+ step: .05
2492
+ },
2493
+ {
2494
+ key: "bowTieLengthRatio",
2495
+ label: "Bow-tie length",
2496
+ description: "Aviation mark length as a ratio of the route's total length",
2497
+ type: "number",
2498
+ min: .02,
2499
+ max: .3,
2500
+ step: .01
2501
+ },
2502
+ {
2503
+ key: "bowTieWidthRatio",
2504
+ label: "Bow-tie width",
2505
+ description: "Aviation mark total width as a ratio of its length",
2506
+ type: "number",
2507
+ min: .1,
2508
+ max: 1.5,
2509
+ step: .05
2510
+ },
2511
+ {
2512
+ key: "bowTiePosition",
2513
+ label: "Bow-tie position",
2514
+ description: "Position of the aviation mark along the route, from tail (0) to tip (1)",
2515
+ type: "number",
2516
+ min: 0,
2517
+ max: 1,
2518
+ step: .01
2519
+ },
2520
+ {
2521
+ key: "labelPosition",
2522
+ label: "Label position",
2523
+ description: "Position of the T/W/W1 group along the route, from tail (0) to tip (1)",
2524
+ type: "number",
2525
+ min: 0,
2526
+ max: 1,
2527
+ step: .01
2528
+ },
2529
+ {
2530
+ key: "labelPadding",
2531
+ label: "Label padding",
2532
+ description: "Extra spacing between the route and the date-time rows, in label heights",
2533
+ type: "number",
2534
+ min: 0,
2535
+ max: 2,
2536
+ step: .05
2537
+ }
2538
+ ],
2539
+ textAmplifiers: [
2540
+ {
2541
+ key: "T",
2542
+ label: "Unique designation",
2543
+ description: "Field T — the attack route's unique designation.",
2544
+ placeholder: "AVON",
2545
+ maxLength: 21
2546
+ },
2547
+ {
2548
+ key: "W",
2549
+ label: "Start date-time group",
2550
+ description: "Field W — the route's start date-time group.",
2551
+ placeholder: "080400ZOCT2008"
2552
+ },
2553
+ {
2554
+ key: "W1",
2555
+ label: "End date-time group",
2556
+ description: "Field W1 — the route's end date-time group.",
2557
+ placeholder: "120300ZOCT2008"
2558
+ }
2559
+ ]
2560
+ };
2561
+ function nonNegative(value, fallback) {
2562
+ return Number.isFinite(value) ? Math.max(0, value) : fallback;
2563
+ }
2564
+ function pointAtDistance(verts, distance) {
2565
+ let walked = 0;
2566
+ for (let i = 0; i < verts.length - 1; i++) {
2567
+ const a = verts[i];
2568
+ const delta = vecSub(verts[i + 1], a);
2569
+ const length = vecMag(delta);
2570
+ if (length < 1e-6) continue;
2571
+ if (distance <= walked + length) return vecAdd(a, vecScale(delta, (distance - walked) / length));
2572
+ walked += length;
2573
+ }
2574
+ return verts[verts.length - 1];
2575
+ }
2576
+ /** Builds the route, aviation bow-tie, arrowhead, and optional T/W/W1 labels. */
2577
+ function createDirectionOfAttackAviation(coordinates, options = {}, textAmplifiers = {}) {
2578
+ const resolved = {
2579
+ ...DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS,
2580
+ ...options
2581
+ };
2582
+ const verts = smoothLineVerts(coordinates.slice().reverse().map((position) => project(position[0], position[1])), resolved, 12);
2583
+ const { segments, totalLength } = polylineSegments(verts);
2584
+ if (segments.length === 0 || totalLength < 1e-6) return {
2585
+ type: "FeatureCollection",
2586
+ features: []
2587
+ };
2588
+ const bowTieLength = Math.min(totalLength, totalLength * nonNegative(resolved.bowTieLengthRatio, DEFAULT_BOW_TIE_LENGTH_RATIO));
2589
+ const bowTiePosition = clampLinePosition(resolved.bowTiePosition, DEFAULT_BOW_TIE_POSITION);
2590
+ const labelPosition = clampLinePosition(resolved.labelPosition, DEFAULT_LABEL_POSITION);
2591
+ const bowTieCenterDistance = totalLength * bowTiePosition;
2592
+ const bowTieStartDistance = Math.max(0, bowTieCenterDistance - bowTieLength / 2);
2593
+ const bowTieEndDistance = Math.min(totalLength, bowTieCenterDistance + bowTieLength / 2);
2594
+ const bowTieStart = pointAtDistance(verts, bowTieStartDistance);
2595
+ const bowTieEnd = pointAtDistance(verts, bowTieEndDistance);
2596
+ const last = segments[segments.length - 1];
2597
+ const tip = vecAdd(last.start, vecScale(last.along, last.length));
2598
+ const arrowheadLength = Math.min(totalLength * nonNegative(resolved.arrowheadLengthRatio, DEFAULT_ARROWHEAD_LENGTH_RATIO$1), totalLength);
2599
+ const arrowheadHalfWidth = arrowheadLength * nonNegative(resolved.arrowheadWidthRatio, DEFAULT_ARROWHEAD_WIDTH_RATIO$1) / 2;
2600
+ const arrowBase = vecSub(tip, vecScale(last.along, arrowheadLength));
2601
+ const arrowLeft = vecAdd(arrowBase, vecScale(last.perp, arrowheadHalfWidth));
2602
+ const arrowRight = vecSub(arrowBase, vecScale(last.perp, arrowheadHalfWidth));
2603
+ if (!bowTieStart || !bowTieEnd) return {
2604
+ type: "FeatureCollection",
2605
+ features: []
2606
+ };
2607
+ const bowTieAlong = vecNorm(vecSub(bowTieEnd, bowTieStart));
2608
+ if (vecMag(bowTieAlong) < 1e-6) return {
2609
+ type: "FeatureCollection",
2610
+ features: []
2611
+ };
2612
+ const bowTiePerp = [-bowTieAlong[1], bowTieAlong[0]];
2613
+ const bowTieHalfWidth = bowTieLength * nonNegative(resolved.bowTieWidthRatio, DEFAULT_BOW_TIE_WIDTH_RATIO) / 2;
2614
+ const bowTieA = vecAdd(bowTieStart, vecScale(bowTiePerp, bowTieHalfWidth));
2615
+ const bowTieB = vecSub(bowTieStart, vecScale(bowTiePerp, bowTieHalfWidth));
2616
+ const bowTieC = vecAdd(bowTieEnd, vecScale(bowTiePerp, bowTieHalfWidth));
2617
+ const bowTieD = vecSub(bowTieEnd, vecScale(bowTiePerp, bowTieHalfWidth));
2618
+ const labelGap = directionLabelGap(totalLength, labelPosition, textAmplifiers.T, resolved);
2619
+ const features = [{
2620
+ type: "Feature",
2621
+ properties: { part: "direction-of-attack-aviation" },
2622
+ geometry: {
2623
+ type: "MultiLineString",
2624
+ coordinates: [
2625
+ ...buildGappedLine(verts, [{
2626
+ g0: bowTieStartDistance,
2627
+ g1: bowTieEndDistance
2628
+ }, ...labelGap ? [labelGap] : []]),
2629
+ [
2630
+ arrowLeft,
2631
+ tip,
2632
+ arrowRight
2633
+ ].map((point) => unproject(point[0], point[1])),
2634
+ [
2635
+ bowTieA,
2636
+ bowTieB,
2637
+ bowTieC,
2638
+ bowTieD,
2639
+ bowTieA
2640
+ ].map((point) => unproject(point[0], point[1]))
2641
+ ]
2642
+ }
2643
+ }];
2644
+ const labelFrame = pointAlongPolyline(segments, totalLength, labelPosition);
2645
+ if (labelFrame) {
2646
+ const labels = [];
2647
+ const rotation = labelRotationAlong(labelFrame.along);
2648
+ const sizeProps = labelSizeProps(resolved);
2649
+ if (textAmplifiers.T) pushLabel(labels, labelFrame.point, textAmplifiers.T, rotation, sizeProps, void 0, "T");
2650
+ const rowOffset = resolveLabelOffsetMeters(resolved, 1 + Math.max(0, resolved.labelPadding ?? 0));
2651
+ if (textAmplifiers.W) pushLabel(labels, vecSub(labelFrame.point, vecScale(labelFrame.perp, rowOffset)), textAmplifiers.W, rotation, sizeProps, void 0, "W");
2652
+ if (textAmplifiers.W1) pushLabel(labels, vecSub(labelFrame.point, vecScale(labelFrame.perp, rowOffset * 2)), textAmplifiers.W1, rotation, sizeProps, void 0, "W1");
2653
+ features.push(...labels);
2654
+ }
2655
+ return {
2656
+ type: "FeatureCollection",
2657
+ features
2658
+ };
2659
+ }
2660
+ const DIRECTION_OF_ATTACK_AVIATION = defineControlMeasure({
2661
+ metadata: DIRECTION_OF_ATTACK_AVIATION_METADATA,
2662
+ generator: createDirectionOfAttackAviation,
2663
+ defaultOptions: DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS,
2664
+ rule: line1DrawRule,
2665
+ previewSample: {
2666
+ controlPoints: [[1, 0], [-1, 0]],
2667
+ textAmplifiers: {
2668
+ T: "AVON",
2669
+ W: "080400ZOCT2008",
2670
+ W1: "120300ZOCT2008"
2671
+ },
2672
+ options: { labelSize: 80 }
2673
+ }
2674
+ });
2675
+ //#endregion
2676
+ //#region src/generators/cm14-maneuver-lines/direction-of-main-attack.ts
2677
+ const DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS = {
2678
+ arrowheadLengthRatio: DEFAULT_DIRECTION_ARROWHEAD_LENGTH_RATIO,
2679
+ arrowheadWidthRatio: DEFAULT_DIRECTION_ARROWHEAD_WIDTH_RATIO,
2680
+ arrowheadOutlineRatio: .3,
2681
+ labelPosition: DEFAULT_DIRECTION_LABEL_POSITION,
2682
+ labelPadding: 0,
2683
+ smooth: false,
2684
+ smoothResolution: 12
2685
+ };
2686
+ const DIRECTION_OF_MAIN_ATTACK_METADATA = {
2687
+ id: "direction-of-main-attack",
2688
+ name: "Direction of Main Attack",
2689
+ description: "The direction or route of the primary attack.",
2690
+ entity: "Maneuver Lines",
2691
+ entityType: "Direction of Attack",
2692
+ entitySubtype: "Main Attack",
2693
+ value: "140602",
2694
+ minCoordinates: 2,
2695
+ geometry: "line",
2696
+ geometryTypes: ["MultiLineString", "Point"],
2697
+ drawRule: "Line1",
2698
+ capturesLabelSize: true,
2699
+ params: [
2700
+ ...SMOOTH_LINE_PARAMS,
2701
+ ...DIRECTION_OF_ATTACK_PARAMS,
2702
+ {
2703
+ key: "arrowheadOutlineRatio",
2704
+ label: "Arrowhead outline",
2705
+ description: "Outlined arrowhead thickness as a ratio of its length",
2706
+ type: "number",
2707
+ min: .05,
2708
+ max: .75,
2709
+ step: .05
2710
+ }
2711
+ ],
2712
+ textAmplifiers: DIRECTION_OF_ATTACK_TEXT_AMPLIFIERS
2713
+ };
2714
+ function createDirectionOfMainAttack(coordinates, options = {}, textAmplifiers = {}) {
2715
+ const resolved = {
2716
+ ...DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS,
2717
+ ...options
2718
+ };
2719
+ return createDirectionOfAttackLine(coordinates, resolved, textAmplifiers, {
2720
+ part: "direction-of-main-attack",
2721
+ outlineRatio: resolved.arrowheadOutlineRatio
2722
+ });
2723
+ }
2724
+ const DIRECTION_OF_MAIN_ATTACK = defineControlMeasure({
2725
+ metadata: DIRECTION_OF_MAIN_ATTACK_METADATA,
2726
+ generator: createDirectionOfMainAttack,
2727
+ defaultOptions: DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS,
2728
+ rule: line1DrawRule,
2729
+ previewSample: {
2730
+ controlPoints: [[1, 0], [-1, 0]],
2731
+ textAmplifiers: {
2732
+ T: "MAIN",
2733
+ ...DIRECTION_OF_ATTACK_PREVIEW_TEXT
2734
+ },
2735
+ options: { labelSize: 80 }
2736
+ }
2737
+ });
2738
+ //#endregion
2739
+ //#region src/generators/cm14-maneuver-lines/direction-of-supporting-attack.ts
2740
+ const DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS = {
2741
+ arrowheadLengthRatio: DEFAULT_DIRECTION_ARROWHEAD_LENGTH_RATIO,
2742
+ arrowheadWidthRatio: DEFAULT_DIRECTION_ARROWHEAD_WIDTH_RATIO,
2743
+ labelPosition: DEFAULT_DIRECTION_LABEL_POSITION,
2744
+ labelPadding: 0,
2745
+ smooth: false,
2746
+ smoothResolution: 12
2747
+ };
2748
+ const DIRECTION_OF_SUPPORTING_ATTACK_METADATA = {
2749
+ id: "direction-of-supporting-attack",
2750
+ name: "Direction of Supporting Attack",
2751
+ description: "The direction or route of an attack that supports the main effort.",
2752
+ entity: "Maneuver Lines",
2753
+ entityType: "Direction of Attack",
2754
+ entitySubtype: "Supporting Attack",
2755
+ value: "140603",
2756
+ minCoordinates: 2,
2757
+ geometry: "line",
2758
+ geometryTypes: ["MultiLineString", "Point"],
2759
+ drawRule: "Line1",
2760
+ capturesLabelSize: true,
2761
+ params: [...SMOOTH_LINE_PARAMS, ...DIRECTION_OF_ATTACK_PARAMS],
2762
+ textAmplifiers: DIRECTION_OF_ATTACK_TEXT_AMPLIFIERS
2763
+ };
2764
+ function createDirectionOfSupportingAttack(coordinates, options = {}, textAmplifiers = {}) {
2765
+ return createDirectionOfAttackLine(coordinates, {
2766
+ ...DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS,
2767
+ ...options
2768
+ }, textAmplifiers, { part: "direction-of-supporting-attack" });
2769
+ }
2770
+ const DIRECTION_OF_SUPPORTING_ATTACK = defineControlMeasure({
2771
+ metadata: DIRECTION_OF_SUPPORTING_ATTACK_METADATA,
2772
+ generator: createDirectionOfSupportingAttack,
2773
+ defaultOptions: DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS,
2774
+ rule: line1DrawRule,
2775
+ previewSample: {
2776
+ controlPoints: [[1, 0], [-1, 0]],
2777
+ textAmplifiers: {
2778
+ T: "YALU",
2779
+ ...DIRECTION_OF_ATTACK_PREVIEW_TEXT
2780
+ },
2781
+ options: { labelSize: 80 }
2782
+ }
2783
+ });
2784
+ //#endregion
2785
+ //#region src/internal/area-echelon.ts
2786
+ /**
2787
+ * Twice the ring's signed area (shoelace). Positive when the ring winds
2788
+ * counter-clockwise in projected (y-up) coordinates. Exported for reuse by
2789
+ * `area-labels.ts`'s polygon-centroid label placement, so the area centroid
2790
+ * shares this one derivation instead of re-deriving it.
2791
+ */
2792
+ function ringSignedArea2(verts) {
2793
+ let area2 = 0;
2794
+ for (let i = 0; i < verts.length - 1; i++) {
2795
+ const a = verts[i];
2796
+ const b = verts[i + 1];
2797
+ area2 += a[0] * b[1] - b[0] * a[1];
2798
+ }
2799
+ return area2;
2800
+ }
2801
+ /**
2802
+ * Projects `positions` and returns a closed ring (first == last), optionally
2803
+ * smoothed through the control points with a closed centripetal Catmull-Rom
2804
+ * spline. A trailing vertex that duplicates the first (already-closed input)
2805
+ * is dropped before closing/smoothing.
2806
+ */
2807
+ function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
2808
+ const projected = positions.map((p) => project(p[0], p[1]));
2809
+ const ringPts = projected.length > 1 && Math.abs(projected[0][0] - projected[projected.length - 1][0]) < 1e-6 && Math.abs(projected[0][1] - projected[projected.length - 1][1]) < 1e-6 ? projected.slice(0, -1) : projected;
2810
+ return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
2811
+ }
2812
+ /**
2813
+ * Walks a closed ring into per-segment metadata (unit direction, outward
2814
+ * normal, cumulative arc length) plus the total perimeter and the arc-length
2815
+ * anchor of the bottom edge — the segment whose midpoint sits lowest (min
2816
+ * projected y), the side opposite the front.
2817
+ */
2818
+ function buildAreaPerimeter(verts) {
2819
+ const ccw = ringSignedArea2(verts) > 0;
2820
+ const segs = [];
2821
+ let totalLength = 0;
2822
+ let anchorTarget = 0;
2823
+ let lowest = Infinity;
2824
+ for (let i = 0; i < verts.length - 1; i++) {
2825
+ const a = verts[i];
2826
+ const b = verts[i + 1];
2827
+ const edge = vecSub(b, a);
2828
+ const length = vecMag(edge);
2829
+ if (length < 1e-6) continue;
2830
+ const along = vecNorm(edge);
2831
+ const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
2832
+ const perp = ccw ? [along[1], -along[0]] : [-along[1], along[0]];
2833
+ segs.push({
2834
+ start: a,
2835
+ along,
2836
+ perp,
2837
+ length,
2838
+ arcStart: totalLength
2839
+ });
2840
+ if (mid[1] < lowest) {
2841
+ lowest = mid[1];
2842
+ anchorTarget = totalLength + length / 2;
2843
+ }
2844
+ totalLength += length;
2845
+ }
2846
+ return {
2847
+ verts,
2848
+ segs,
2849
+ totalLength,
2850
+ anchorTarget
2851
+ };
2852
+ }
2853
+ /** Resolves the arc-length `target` to a point and the segment frame it lands on. */
2854
+ function pointOnRing(segs, target) {
2855
+ let seg = segs[segs.length - 1];
2856
+ for (const candidate of segs) if (target < candidate.arcStart + candidate.length) {
2857
+ seg = candidate;
2858
+ break;
2859
+ }
2860
+ return {
2861
+ point: vecAdd(seg.start, vecScale(seg.along, target - seg.arcStart)),
2862
+ seg
2863
+ };
2864
+ }
2865
+ /**
2866
+ * Effective echelon glyph height in meters. Screen-anchored when a pixel size
2867
+ * is supplied with a live resolution; ground-anchored (meters) otherwise.
2868
+ * Clamped strictly positive.
2869
+ */
2870
+ function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
2871
+ let h = echelonSize;
2872
+ if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
2873
+ return Math.max(EPSILON, h);
2874
+ }
2875
+ /** Wraps `x` into `[0, 1)`, treating `x` as a fraction (matches the arc-length wrap below). */
2876
+ function wrap01(x) {
2877
+ return (x % 1 + 1) % 1;
2878
+ }
2879
+ /**
2880
+ * Places the Field B echelon glyph on the perimeter and computes its masking
2881
+ * gap, anchored by default to the bottom edge and slid by `echelonPosition`
2882
+ * (a signed fraction of the perimeter, wrapping at the seam). When
2883
+ * `echelonAnchor` is supplied, it replaces the bottom-edge anchor with an
2884
+ * absolute perimeter fraction (measured from the ring seam) — see
2885
+ * `BattlePositionOptions.echelonAnchor` for why (ADR-0023 rotate-freeze).
2886
+ * Returns empty geometry when the echelon resolves to none or the ring is
2887
+ * degenerate.
2888
+ */
2889
+ function placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, echelonAnchor) {
2890
+ const { segs, totalLength, anchorTarget } = perimeter;
2891
+ const spec = resolveEchelon(echelon);
2892
+ if (!spec || segs.length === 0 || totalLength < 1e-6) return {
2893
+ strokes: [],
2894
+ fills: [],
2895
+ gaps: []
2896
+ };
2897
+ const target = (((echelonAnchor !== void 0 ? wrap01(echelonAnchor) * totalLength : anchorTarget) + echelonPosition * totalLength) % totalLength + totalLength) % totalLength;
2898
+ const { point, seg } = pointOnRing(segs, target);
2899
+ const { strokes, fills, width } = buildEchelonGlyph(point, seg.along, seg.perp, h, spec);
2900
+ const gaps = [];
2901
+ const halfGap = width / 2 + Math.max(0, echelonPadding) * h;
2902
+ pushWrappedGap(gaps, target - halfGap, target + halfGap, totalLength);
2903
+ return {
2904
+ strokes,
2905
+ fills,
2906
+ gaps
2907
+ };
2908
+ }
2909
+ /**
2910
+ * Factory for the `freezeOrientation` hook (ADR-0023) shared by every
2911
+ * echelon-carrying closed-area measure (Battle Position, Strong Point, Base
2912
+ * Camp, Guerrilla Base): pins the echelon anchor to its current bottom-edge
2913
+ * fraction so a subsequent rigid rotation of `controlPoints` (e.g. the
2914
+ * transform box's rotate grip) does not re-derive a different "bottom edge"
2915
+ * against the rotated geometry. `defaults` supplies the measure's own
2916
+ * `echelon`/`smooth`/`smoothResolution` defaults, since each measure's
2917
+ * `DEFAULT_*_OPTIONS` differ (e.g. Battle Position's `echelon: "battalion"`
2918
+ * vs. Base Camp/Guerrilla Base's `echelon: "none"`).
2919
+ *
2920
+ * The returned hook returns `undefined` — nothing to freeze — when:
2921
+ * - `echelonAnchor` is already set (previously frozen or user-authored), or
2922
+ * - the echelon resolves to none (nothing visible to pin), or
2923
+ * - the ring is degenerate (no segments, or a near-zero perimeter).
2924
+ */
2925
+ function makeFreezeEchelonAnchor(defaults) {
2926
+ return function freezeEchelonAnchor(controlPoints, options) {
2927
+ if (options.echelonAnchor !== void 0) return void 0;
2928
+ if (!resolveEchelon(options.echelon ?? defaults.echelon)) return void 0;
2929
+ const perimeter = buildAreaPerimeter(buildClosedRing(controlPoints, options.smooth ?? defaults.smooth, options.smoothResolution ?? defaults.smoothResolution, defaults.smoothResolution));
2930
+ if (perimeter.segs.length === 0 || perimeter.totalLength < 1e-6) return void 0;
2931
+ return { echelonAnchor: perimeter.anchorTarget / perimeter.totalLength };
2932
+ };
2933
+ }
2934
+ //#endregion
2935
+ //#region src/internal/area-labels.ts
2936
+ /** Stored label rotation for horizontal (screen-upright) area text — adapters
2937
+ * render feature rotation as `π − rotation`, so `π` renders as `0`. Exported
2938
+ * for reuse by measures that place a horizontal label outside the area's
2939
+ * boundary (e.g. Airfield Zone's Field H). */
2940
+ const HORIZONTAL_LABEL_ROTATION = Math.PI;
2941
+ /** Row pitch, as a ratio of the resolved label height, between stacked
2942
+ * name/H/W-W1 rows (see {@link pushAreaLabels}). */
2943
+ const ROW_PITCH_RATIO = 1.2;
2944
+ /** Clearance, as a ratio of the resolved label height, added on top of each
2362
2945
  * ENY marker's own half-width/half-height when carving its boundary
2363
2946
  * knockout gap (see {@link pushAreaLabels}). */
2364
2947
  const ENY_CLEARANCE_RATIO = .3;
@@ -2431,12 +3014,6 @@ function ringCentroid(verts) {
2431
3014
  const factor = 1 / (3 * area2);
2432
3015
  return [cx * factor, cy * factor];
2433
3016
  }
2434
- /** Joins the W/W1 date-time-group pair as `"W - W1"`; a single supplied value
2435
- * passes through unchanged; neither set yields `""`. */
2436
- function joinDtg(start, end) {
2437
- if (start && end) return `${start} - ${end}`;
2438
- return start ?? end ?? "";
2439
- }
2440
3017
  /**
2441
3018
  * Points where the closed ring `verts` crosses the horizontal line
2442
3019
  * `y = target`, one per crossing edge, plus the ring's total arc length —
@@ -2480,7 +3057,7 @@ function ringCrossingsAtY(verts, target) {
2480
3057
  * {@link buildGappedLine} uses) an "ENY" marker knocks out of the boundary —
2481
3058
  * empty unless `texts.hostile` was placed.
2482
3059
  *
2483
- * Rows are `[name, additionalInformation, joinDtg(dtgStart, dtgEnd)]`,
3060
+ * Rows are `[name (T), additionalInformation (H), dtgStart (W), dtgEnd (W1)]`,
2484
3061
  * filtered to non-empty text, stacked around the ring's {@link ringCentroid}
2485
3062
  * at `pitch = resolveLabelOffsetMeters(options, 1.2 + labelPadding)` (the 50m
2486
3063
  * no-size fallback applies until a live label size is captured — see
@@ -2514,14 +3091,18 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
2514
3091
  const padding = Math.max(0, options.labelPadding ?? 0);
2515
3092
  const [cx, cy] = ringCentroid(ringVerts);
2516
3093
  const rows = [
2517
- texts.name,
2518
- texts.additionalInformation,
2519
- joinDtg(texts.dtgStart, texts.dtgEnd)
2520
- ].filter((s) => Boolean(s && s.length > 0));
3094
+ [texts.name, "T"],
3095
+ [texts.additionalInformation, "H"],
3096
+ [texts.dtgStart, "W"],
3097
+ [texts.dtgEnd, "W1"]
3098
+ ].flatMap(([text, field]) => text ? [{
3099
+ text,
3100
+ field
3101
+ }] : []);
2521
3102
  if (rows.length > 0) {
2522
3103
  const pitch = resolveLabelOffsetMeters(options, ROW_PITCH_RATIO + padding);
2523
- rows.forEach((text, i) => {
2524
- pushLabel(out, [cx, cy + ((rows.length - 1) / 2 - i) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps);
3104
+ rows.forEach(({ text, field }, i) => {
3105
+ pushLabel(out, [cx, cy + ((rows.length - 1) / 2 - i) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, field);
2525
3106
  });
2526
3107
  }
2527
3108
  const gaps = [];
@@ -2535,7 +3116,7 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
2535
3116
  const textWidth = texts.hostile.length * ENY_CHAR_ASPECT_RATIO * textHeight;
2536
3117
  const clearance = (ENY_CLEARANCE_RATIO + padding) * textHeight;
2537
3118
  for (const crossing of [west, east]) {
2538
- pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps);
3119
+ pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N");
2539
3120
  const halfExtent = .5 * (textWidth * Math.abs(crossing.along[0]) + textHeight * Math.abs(crossing.along[1])) + clearance;
2540
3121
  pushWrappedGap(gaps, crossing.arc - halfExtent, crossing.arc + halfExtent, totalArc);
2541
3122
  }
@@ -2623,20 +3204,217 @@ const AREA_METADATA = {
2623
3204
  function createArea(positions, options = {}, textAmplifiers = {}) {
2624
3205
  return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""));
2625
3206
  }
2626
- const AREA = defineControlMeasure({
2627
- metadata: AREA_METADATA,
2628
- generator: createArea,
2629
- defaultOptions: DEFAULT_AREA_OPTIONS,
2630
- rule: area1DrawRule,
3207
+ const AREA = defineControlMeasure({
3208
+ metadata: AREA_METADATA,
3209
+ generator: createArea,
3210
+ defaultOptions: DEFAULT_AREA_OPTIONS,
3211
+ rule: area1DrawRule,
3212
+ previewSample: {
3213
+ controlPoints: [
3214
+ [-1, -.5],
3215
+ [1, -.5],
3216
+ [1, .5],
3217
+ [-1, .5]
3218
+ ],
3219
+ textAmplifiers: {},
3220
+ options: { labelSize: 96 }
3221
+ }
3222
+ });
3223
+ //#endregion
3224
+ //#region src/generators/cm15-maneuver-areas/area-defense.ts
3225
+ const DEFAULT_AREA_DEFENSE_OPTIONS = {
3226
+ arrowOpeningAngle: 30,
3227
+ labelOpeningAngle: 20,
3228
+ barbCount: 6,
3229
+ barbLengthRatio: .16,
3230
+ arrowheadLengthRatio: .18
3231
+ };
3232
+ const AREA_DEFENSE_METADATA = {
3233
+ id: "area-defense",
3234
+ name: "Area Defense",
3235
+ description: "A defensive posture oriented in all directions and focused on retaining specified terrain.",
3236
+ entity: "Maneuver Areas",
3237
+ entityType: "Area Defense",
3238
+ value: "152600",
3239
+ minCoordinates: 2,
3240
+ maxCoordinates: 2,
3241
+ geometry: "area",
3242
+ geometryTypes: [
3243
+ "MultiLineString",
3244
+ "MultiPolygon",
3245
+ "Point"
3246
+ ],
3247
+ drawRule: "Area15",
3248
+ capturesLabelSize: true,
3249
+ params: [
3250
+ {
3251
+ key: "arrowOpeningAngle",
3252
+ label: "Arrow opening",
3253
+ description: "Angular opening between the two arrow tips, in degrees",
3254
+ type: "number",
3255
+ min: 5,
3256
+ max: 120,
3257
+ step: 1
3258
+ },
3259
+ {
3260
+ key: "labelOpeningAngle",
3261
+ label: "AD opening",
3262
+ description: "Angular opening occupied by the AD label, in degrees",
3263
+ type: "number",
3264
+ min: 5,
3265
+ max: 90,
3266
+ step: 1
3267
+ },
3268
+ {
3269
+ key: "barbCount",
3270
+ label: "Barbs",
3271
+ description: "Number of filled outward barbs distributed around the defense",
3272
+ type: "number",
3273
+ min: 2,
3274
+ max: 20,
3275
+ step: 1
3276
+ },
3277
+ {
3278
+ key: "barbLengthRatio",
3279
+ label: "Barb length",
3280
+ description: "Barb height as a ratio of the symbol radius",
3281
+ type: "number",
3282
+ min: .02,
3283
+ max: .5,
3284
+ step: .01
3285
+ },
3286
+ {
3287
+ key: "arrowheadLengthRatio",
3288
+ label: "Arrowhead length",
3289
+ description: "Open-arrowhead length as a ratio of the symbol radius",
3290
+ type: "number",
3291
+ min: .02,
3292
+ max: .5,
3293
+ step: .01
3294
+ }
3295
+ ]
3296
+ };
3297
+ function pointOnCircle(center, radius, angle) {
3298
+ return vecAdd(center, [radius * Math.cos(angle), radius * Math.sin(angle)]);
3299
+ }
3300
+ function sampleArc(center, radius, start, end) {
3301
+ const span = end - start;
3302
+ const segments = Math.max(8, Math.round(48 * Math.abs(span) / Math.PI));
3303
+ return Array.from({ length: segments + 1 }, (_, index) => pointOnCircle(center, radius, start + span * index / segments));
3304
+ }
3305
+ function openArrowhead(tip, direction, length) {
3306
+ const base = vecSub(tip, [direction[0] * length, direction[1] * length]);
3307
+ const perp = [-direction[1], direction[0]];
3308
+ return [
3309
+ vecAdd(base, [perp[0] * length * .55, perp[1] * length * .55]),
3310
+ tip,
3311
+ vecSub(base, [perp[0] * length * .55, perp[1] * length * .55])
3312
+ ];
3313
+ }
3314
+ function createAreaDefense(coordinates, options = {}) {
3315
+ const resolved = {
3316
+ ...DEFAULT_AREA_DEFENSE_OPTIONS,
3317
+ ...options
3318
+ };
3319
+ const center = project(coordinates[0][0], coordinates[0][1]);
3320
+ const radiusVector = vecSub(project(coordinates[1][0], coordinates[1][1]), center);
3321
+ const radius = vecMag(radiusVector);
3322
+ if (radius < 1e-6) return {
3323
+ type: "FeatureCollection",
3324
+ features: []
3325
+ };
3326
+ const upperTipAngle = Math.atan2(radiusVector[1], radiusVector[0]);
3327
+ const arrowHalf = Math.min(120, Math.max(5, resolved.arrowOpeningAngle)) * Math.PI / 360;
3328
+ const labelHalf = Math.min(90, Math.max(5, resolved.labelOpeningAngle)) * Math.PI / 360;
3329
+ const labelCenter = upperTipAngle + arrowHalf - Math.PI;
3330
+ const upperStart = labelCenter + labelHalf;
3331
+ const upperEnd = upperTipAngle;
3332
+ const lowerStart = labelCenter - labelHalf;
3333
+ const lowerEnd = upperTipAngle + arrowHalf * 2 - Math.PI * 2;
3334
+ const upperArc = sampleArc(center, radius, upperStart, upperEnd);
3335
+ const lowerArc = sampleArc(center, radius, lowerStart, lowerEnd);
3336
+ const arrowLength = radius * Math.max(0, resolved.arrowheadLengthRatio);
3337
+ const upperDirection = [-Math.sin(upperEnd), Math.cos(upperEnd)];
3338
+ const lowerDirection = [Math.sin(lowerEnd), -Math.cos(lowerEnd)];
3339
+ const lineParts = [
3340
+ upperArc,
3341
+ lowerArc,
3342
+ openArrowhead(upperArc.at(-1), upperDirection, arrowLength),
3343
+ openArrowhead(lowerArc.at(-1), lowerDirection, arrowLength)
3344
+ ];
3345
+ const barbCount = Math.max(2, Math.round(resolved.barbCount));
3346
+ const barbLength = radius * Math.max(0, resolved.barbLengthRatio);
3347
+ const barbHalfAngle = Math.asin(Math.min(1, barbLength / 2 / radius));
3348
+ const barbArcs = [{
3349
+ start: upperStart,
3350
+ end: upperEnd,
3351
+ count: Math.ceil(barbCount / 2)
3352
+ }, {
3353
+ start: lowerStart,
3354
+ end: lowerEnd,
3355
+ count: Math.floor(barbCount / 2)
3356
+ }];
3357
+ const barbPolygons = [];
3358
+ for (const arc of barbArcs) for (let ordinal = 1; ordinal <= arc.count; ordinal++) {
3359
+ const angle = arc.start + (arc.end - arc.start) * ordinal / (arc.count + 1);
3360
+ const left = pointOnCircle(center, radius, angle - barbHalfAngle);
3361
+ const right = pointOnCircle(center, radius, angle + barbHalfAngle);
3362
+ const ring = [
3363
+ left,
3364
+ pointOnCircle(center, radius + barbLength, angle),
3365
+ right,
3366
+ left
3367
+ ].map((point) => unproject(point[0], point[1]));
3368
+ barbPolygons.push([ring]);
3369
+ }
3370
+ const labelPoint = pointOnCircle(center, radius, labelCenter);
3371
+ return {
3372
+ type: "FeatureCollection",
3373
+ features: [
3374
+ {
3375
+ type: "Feature",
3376
+ properties: { part: "area-defense" },
3377
+ geometry: {
3378
+ type: "MultiLineString",
3379
+ coordinates: lineParts.map((line) => line.map((point) => unproject(point[0], point[1])))
3380
+ }
3381
+ },
3382
+ {
3383
+ type: "Feature",
3384
+ properties: {
3385
+ part: "barbs",
3386
+ fill: true
3387
+ },
3388
+ geometry: {
3389
+ type: "MultiPolygon",
3390
+ coordinates: barbPolygons
3391
+ }
3392
+ },
3393
+ {
3394
+ type: "Feature",
3395
+ properties: {
3396
+ part: "label",
3397
+ labelPlacement: false,
3398
+ text: "AD",
3399
+ rotation: Math.PI,
3400
+ ...labelSizeProps(options)
3401
+ },
3402
+ geometry: {
3403
+ type: "Point",
3404
+ coordinates: unproject(labelPoint[0], labelPoint[1])
3405
+ }
3406
+ }
3407
+ ]
3408
+ };
3409
+ }
3410
+ const AREA_DEFENSE = defineControlMeasure({
3411
+ metadata: AREA_DEFENSE_METADATA,
3412
+ generator: createAreaDefense,
3413
+ defaultOptions: DEFAULT_AREA_DEFENSE_OPTIONS,
3414
+ rule: centerRadiusDrawRule,
2631
3415
  previewSample: {
2632
- controlPoints: [
2633
- [-1, -.5],
2634
- [1, -.5],
2635
- [1, .5],
2636
- [-1, .5]
2637
- ],
2638
- textAmplifiers: {},
2639
- options: { labelSize: 96 }
3416
+ controlPoints: [[0, 0], [-.77, .21]],
3417
+ options: { labelSize: 90 }
2640
3418
  }
2641
3419
  });
2642
3420
  //#endregion
@@ -2893,7 +3671,7 @@ function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
2893
3671
  }
2894
3672
  if (textAmplifiers.H) {
2895
3673
  const labelFeatures = [];
2896
- pushLabel(labelFeatures, [maxX + H_LABEL_OFFSET_RATIO * resolveLabelOffsetMeters(labelOptions, 1), cy], textAmplifiers.H, HORIZONTAL_LABEL_ROTATION, labelSizeProps(labelOptions), "start");
3674
+ pushLabel(labelFeatures, [maxX + H_LABEL_OFFSET_RATIO * resolveLabelOffsetMeters(labelOptions, 1), cy], textAmplifiers.H, HORIZONTAL_LABEL_ROTATION, labelSizeProps(labelOptions), "start", "H");
2897
3675
  features.push(...labelFeatures);
2898
3676
  }
2899
3677
  return {
@@ -3339,7 +4117,11 @@ function generateAntitankDitchTriangles(projectedPoints, toothHeight, toothWidth
3339
4117
  * unchanged (no project/unproject round trip) when smoothing is off.
3340
4118
  */
3341
4119
  function smoothedDitchBasePath(positions, options) {
3342
- return smoothLineVerts(positions.map((position) => project(position[0], position[1])), options, 16);
4120
+ const projectedPoints = positions.map((position) => project(position[0], position[1]));
4121
+ const smoothedPath = smoothLineVerts(projectedPoints, options, 16);
4122
+ const toothHeight = calculateAntitankDitchHeight(options);
4123
+ const toothWidthRatio = Math.max(EPSILON, options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1);
4124
+ return options.smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, toothWidthRatio * toothHeight) : smoothedPath;
3343
4125
  }
3344
4126
  function buildAntitankDitchTriangles(basePath, options) {
3345
4127
  return generateAntitankDitchTriangles(basePath, calculateAntitankDitchHeight(options), options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1);
@@ -3447,6 +4229,8 @@ function createAntitankWall(positions, options = {}) {
3447
4229
  const toothHeight = calculateAntitankDitchHeight(options);
3448
4230
  const toothWidthRatio = options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO;
3449
4231
  const toothSpacingRatio = options.toothSpacingRatio ?? DEFAULT_TOOTH_SPACING_RATIO;
4232
+ const projectedPoints = positions.map((position) => project(position[0], position[1]));
4233
+ const smoothedPath = smoothLineVerts(projectedPoints, options, 16);
3450
4234
  return {
3451
4235
  type: "FeatureCollection",
3452
4236
  features: [{
@@ -3454,7 +4238,7 @@ function createAntitankWall(positions, options = {}) {
3454
4238
  properties: { part: "wall" },
3455
4239
  geometry: {
3456
4240
  type: "MultiLineString",
3457
- coordinates: [generateAntitankWallPoints(smoothLineVerts(positions.map((position) => project(position[0], position[1])), options, 16), toothHeight, toothWidthRatio, toothSpacingRatio).map((point) => unproject(point[0], point[1]))]
4241
+ coordinates: [generateAntitankWallPoints(options.smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, Math.max(EPSILON, toothWidthRatio) * toothHeight) : smoothedPath, toothHeight, toothWidthRatio, toothSpacingRatio).map((point) => unproject(point[0], point[1]))]
3458
4242
  }
3459
4243
  }]
3460
4244
  };
@@ -4716,11 +5500,13 @@ function createEngineerWorkLine(positions, options = {}, textAmplifiers = {}) {
4716
5500
  const units = [{
4717
5501
  side: 1,
4718
5502
  designator: textAmplifiers.T ?? "",
4719
- country: textAmplifiers.AS ?? ""
5503
+ country: textAmplifiers.AS ?? "",
5504
+ amplifierField: "T"
4720
5505
  }, {
4721
5506
  side: -1,
4722
5507
  designator: textAmplifiers.T1 ?? "",
4723
- country: textAmplifiers.AS1 ?? ""
5508
+ country: textAmplifiers.AS1 ?? "",
5509
+ amplifierField: "T1"
4724
5510
  }];
4725
5511
  const labelFeatures = [];
4726
5512
  pushUnitLabels(labelFeatures, {
@@ -4920,6 +5706,7 @@ function createLabelFeature(point, text, rotation, labelSize = {}) {
4920
5706
  type: "Feature",
4921
5707
  properties: {
4922
5708
  part: "label",
5709
+ labelPlacement: false,
4923
5710
  text,
4924
5711
  rotation,
4925
5712
  ...labelSizeProps(labelSize)
@@ -5020,7 +5807,7 @@ const DEFAULT_BLOCK_MISSION_TASK_OPTIONS = { labelGapRatio: .2 };
5020
5807
  const BLOCK_MISSION_TASK_METADATA = {
5021
5808
  id: "block-mission-task",
5022
5809
  name: "Block",
5023
- description: "A tactical mission task that denies the enemy access to an area or prevents enemy advance in a direction or along an avenue of approach.",
5810
+ description: "Denies the enemy access to an area or prevents the enemy's advance in a direction or along an avenue of approach.",
5024
5811
  entity: "Mission Tasks",
5025
5812
  entityType: "Block",
5026
5813
  value: "340100",
@@ -5112,7 +5899,7 @@ const DEFAULT_BREACH_OPTIONS = {
5112
5899
  const BREACH_METADATA = {
5113
5900
  id: "breach",
5114
5901
  name: "Breach",
5115
- description: "A tactical mission task in which a unit breaks through or establishes a passage through an enemy obstacle.",
5902
+ description: "Employs all available means to break through or secure a passage through an enemy defense, obstacle, minefield, or fortification.",
5116
5903
  entity: "Mission Tasks",
5117
5904
  entityType: "Breach",
5118
5905
  value: "340200",
@@ -5208,7 +5995,7 @@ const DEFAULT_BYPASS_OPTIONS = {
5208
5995
  const BYPASS_METADATA = {
5209
5996
  id: "bypass",
5210
5997
  name: "Bypass",
5211
- description: "A tactical mission task in which a unit maneuvers around an obstacle, position, or enemy force to maintain the momentum of the operation.",
5998
+ description: "Maneuvers around an obstacle, position, or enemy force to maintain the momentum of the operation.",
5212
5999
  entity: "Mission Tasks",
5213
6000
  entityType: "Bypass",
5214
6001
  value: "340300",
@@ -5283,7 +6070,7 @@ const DEFAULT_CANALIZE_OPTIONS = {
5283
6070
  const CANALIZE_METADATA = {
5284
6071
  id: "canalize",
5285
6072
  name: "Canalize",
5286
- description: "Canalize is a tactical mission task in which a unit restricts enemy movement to a narrow zone",
6073
+ description: "Restricts enemy movement to a narrow zone by exploiting terrain coupled with the use of obstacles, fires, or friendly maneuver.",
5287
6074
  entity: "Mission Tasks",
5288
6075
  entityType: "Canalize",
5289
6076
  value: "340400",
@@ -5904,184 +6691,576 @@ function createGenericRectangle(coordinates, options = {}) {
5904
6691
  p1
5905
6692
  ].map((point) => unproject(point[0], point[1]));
5906
6693
  return {
5907
- type: "FeatureCollection",
5908
- features: [{
5909
- type: "Feature",
5910
- properties: {
5911
- part: "rectangle",
5912
- fill
5913
- },
5914
- geometry: {
5915
- type: "Polygon",
5916
- coordinates: [ring]
5917
- }
5918
- }]
6694
+ type: "FeatureCollection",
6695
+ features: [{
6696
+ type: "Feature",
6697
+ properties: {
6698
+ part: "rectangle",
6699
+ fill
6700
+ },
6701
+ geometry: {
6702
+ type: "Polygon",
6703
+ coordinates: [ring]
6704
+ }
6705
+ }]
6706
+ };
6707
+ }
6708
+ const GENERIC_RECTANGLE = defineControlMeasure({
6709
+ metadata: GENERIC_RECTANGLE_METADATA,
6710
+ generator: createGenericRectangle,
6711
+ defaultOptions: DEFAULT_GENERIC_RECTANGLE_OPTIONS,
6712
+ rule: rectangleDrawRule,
6713
+ previewSample: { controlPoints: [
6714
+ [-.9, -.55],
6715
+ [.9, -.55],
6716
+ [.9, .55]
6717
+ ] }
6718
+ });
6719
+ //#endregion
6720
+ //#region src/generators/cm34-mission-tasks/clear.ts
6721
+ /**
6722
+ * Default options for the CLEAR symbol.
6723
+ */
6724
+ const DEFAULT_CLEAR_OPTIONS = {
6725
+ arrowInsetRatio: .15,
6726
+ arrowheadLengthRatio: .15,
6727
+ arrowheadWidthRatio: .2,
6728
+ labelGapRatio: .2
6729
+ };
6730
+ const CLEAR_METADATA = {
6731
+ id: "clear",
6732
+ name: "Clear",
6733
+ description: "Removes all enemy forces and eliminates organized resistance within an assigned area.",
6734
+ entity: "Mission Tasks",
6735
+ entityType: "Clear",
6736
+ value: "340500",
6737
+ minCoordinates: 3,
6738
+ maxCoordinates: 3,
6739
+ geometry: "line",
6740
+ geometryTypes: ["MultiLineString", "Point"],
6741
+ drawRule: "Line23",
6742
+ capturesLabelSize: true,
6743
+ params: [
6744
+ {
6745
+ key: "arrowInsetRatio",
6746
+ label: "Arrow padding",
6747
+ description: "Padding between the line endpoints and the outer arrows, as a ratio of height",
6748
+ type: "number",
6749
+ min: 0,
6750
+ max: .45,
6751
+ step: .01
6752
+ },
6753
+ {
6754
+ key: "arrowheadLengthRatio",
6755
+ label: "Arrowhead length",
6756
+ description: "Arrowhead length as a ratio of the symbol height",
6757
+ type: "number",
6758
+ min: .01,
6759
+ max: 1,
6760
+ step: .01
6761
+ },
6762
+ {
6763
+ key: "arrowheadWidthRatio",
6764
+ label: "Arrowhead width",
6765
+ description: "Arrowhead base width as a ratio of the symbol height",
6766
+ type: "number",
6767
+ min: .01,
6768
+ max: 1,
6769
+ step: .01
6770
+ },
6771
+ {
6772
+ key: "labelGapRatio",
6773
+ label: "Label gap",
6774
+ description: "Size of the shaft gap holding the 'C' label, as a ratio of the shaft length",
6775
+ type: "number",
6776
+ min: 0,
6777
+ max: 1,
6778
+ step: .01
6779
+ }
6780
+ ]
6781
+ };
6782
+ /**
6783
+ * Generates a GeoJSON FeatureCollection for a CLEAR mission task symbol (340500).
6784
+ *
6785
+ * A vertical line (PT. 1 → PT. 2) with three arrows pointing at it,
6786
+ * perpendicular to the line. The arrows come from the rear — the side PT. 3
6787
+ * sits on — and their tips touch the vertical line; each carries a shaft
6788
+ * reaching back to the rear. The arrows are evenly spaced along the line, so
6789
+ * the middle arrow's tip sits at the midpoint of the vertical line and its
6790
+ * shaft carries the 'C' glyph. The arrows stay perpendicular to the vertical
6791
+ * line at any rotation.
6792
+ *
6793
+ * Input Points:
6794
+ * - PT. 1: Top endpoint of the vertical line
6795
+ * - PT. 2: Bottom endpoint of the vertical line — defines height with PT. 1
6796
+ * - PT. 3: Rear point — defines the shaft length (and which side the arrows
6797
+ * come from)
6798
+ *
6799
+ * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
6800
+ * @param options - Configuration options
6801
+ * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
6802
+ */
6803
+ function createClearSymbol(coordinates, options = {}) {
6804
+ const arrowInsetRatio = Math.min(.49, Math.max(0, options.arrowInsetRatio ?? DEFAULT_CLEAR_OPTIONS.arrowInsetRatio));
6805
+ const arrowheadLengthRatio = options.arrowheadLengthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadLengthRatio;
6806
+ const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadWidthRatio;
6807
+ const labelGapRatio = options.labelGapRatio ?? DEFAULT_CLEAR_OPTIONS.labelGapRatio;
6808
+ const [c1, c2, c3] = coordinates;
6809
+ const p1 = project(c1[0], c1[1]);
6810
+ const p2 = project(c2[0], c2[1]);
6811
+ const p3 = project(c3[0], c3[1]);
6812
+ const vLine = vecSub(p2, p1);
6813
+ const height = vecMag(vLine);
6814
+ if (height < 1e-6) return {
6815
+ type: "FeatureCollection",
6816
+ features: []
6817
+ };
6818
+ const dirLine = vecScale(vLine, 1 / height);
6819
+ const midLine = vecScale(vecAdd(p1, p2), .5);
6820
+ const rawPerp = [-dirLine[1], dirLine[0]];
6821
+ const perpToRear = vecScale(rawPerp, vecDot(vecSub(p3, midLine), rawPerp) < 0 ? -1 : 1);
6822
+ const shaftLength = Math.abs(vecDot(vecSub(p3, midLine), rawPerp));
6823
+ const arrowDir = vecScale(perpToRear, -1);
6824
+ const arrowheadLength = height * arrowheadLengthRatio;
6825
+ const arrowheadWidth = height * arrowheadWidthRatio;
6826
+ const tipAt = (t) => vecAdd(p1, vecScale(dirLine, t * height));
6827
+ const lines = [[unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]];
6828
+ const fullTs = [
6829
+ arrowInsetRatio,
6830
+ .5,
6831
+ 1 - arrowInsetRatio
6832
+ ];
6833
+ const labelIndex = 1;
6834
+ fullTs.forEach((t, i) => {
6835
+ const tip = tipAt(t);
6836
+ const rearEnd = vecAdd(tip, vecScale(perpToRear, shaftLength));
6837
+ if (i === labelIndex && shaftLength > 1e-6) {
6838
+ const labelCenter = vecAdd(tip, vecScale(perpToRear, shaftLength * .5));
6839
+ const gapHalf = shaftLength * labelGapRatio / 2;
6840
+ const gapRear = vecAdd(labelCenter, vecScale(perpToRear, gapHalf));
6841
+ const gapTip = vecAdd(labelCenter, vecScale(perpToRear, -gapHalf));
6842
+ lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
6843
+ lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
6844
+ } else if (shaftLength > 1e-6) lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(tip[0], tip[1])]);
6845
+ lines.push(createArrowHead$1(tip, arrowDir, arrowheadLength, arrowheadWidth));
6846
+ });
6847
+ const mainFeature = {
6848
+ type: "Feature",
6849
+ properties: { part: "clear" },
6850
+ geometry: {
6851
+ type: "MultiLineString",
6852
+ coordinates: lines
6853
+ }
6854
+ };
6855
+ const lineRotation = Math.atan2(dirLine[1], dirLine[0]);
6856
+ const renderedLabelRotation = Math.PI - (lineRotation - Math.PI / 2);
6857
+ const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
6858
+ const labelTip = tipAt(fullTs[labelIndex]);
6859
+ return {
6860
+ type: "FeatureCollection",
6861
+ features: [mainFeature, createLabelFeature(shaftLength > 1e-6 ? vecAdd(labelTip, vecScale(perpToRear, shaftLength * .5)) : labelTip, "C", labelRotation, {
6862
+ labelSize: options.labelSize,
6863
+ labelSizePixels: options.labelSizePixels
6864
+ })]
6865
+ };
6866
+ }
6867
+ const CLEAR = defineControlMeasure({
6868
+ metadata: CLEAR_METADATA,
6869
+ generator: createClearSymbol,
6870
+ defaultOptions: DEFAULT_CLEAR_OPTIONS,
6871
+ rule: line23DrawRule
6872
+ });
6873
+ //#endregion
6874
+ //#region src/internal/field-of-fire.ts
6875
+ /**
6876
+ * Builds the open (stroked) arrowhead at the outward tip of one arm. The two
6877
+ * barbs are sized relative to that arm's own length, so each arm's head scales
6878
+ * independently (Line3 — arms vary independently). Returns `null` when the arm
6879
+ * has collapsed to zero length.
6880
+ */
6881
+ function arrowheadAt(vertex, tip, hSize, hWidth) {
6882
+ const v = vecSub(tip, vertex);
6883
+ const armLength = vecMag(v);
6884
+ if (armLength < 1e-6) return null;
6885
+ const dir = vecNorm(v);
6886
+ const perp = [-dir[1], dir[0]];
6887
+ const headLength = armLength * hSize;
6888
+ const halfWidth = headLength * hWidth;
6889
+ const base = vecSub(tip, vecScale(dir, headLength));
6890
+ return [
6891
+ vecAdd(base, vecScale(perp, halfWidth)),
6892
+ tip,
6893
+ vecSub(base, vecScale(perp, halfWidth))
6894
+ ];
6895
+ }
6896
+ /**
6897
+ * Projects the vertex (PT 1) and up to two tips and builds the V of arms, each
6898
+ * arm ending in an open arrowhead. Renders one arm per tip, so two points
6899
+ * (vertex + one tip) draws the single arm being placed — the live feedback
6900
+ * after PT 1 — and three points draws the full V. Points beyond the third are
6901
+ * ignored (input contract, ADR-0014).
6902
+ *
6903
+ * Returns `null` if any arm is degenerate (collapsed to zero length), the
6904
+ * silent ADR-0014 empty-render signal.
6905
+ */
6906
+ function buildFieldOfFireV(coordinates, hSize, hWidth) {
6907
+ const vertexCoord = coordinates[0];
6908
+ const vertex = project(vertexCoord[0], vertexCoord[1]);
6909
+ const tips = coordinates.slice(1, 3).map((c) => project(c[0], c[1]));
6910
+ const lines = [];
6911
+ for (const tip of tips) {
6912
+ const head = arrowheadAt(vertex, tip, hSize, hWidth);
6913
+ if (!head) return null;
6914
+ lines.push([vertex, tip], head);
6915
+ }
6916
+ return {
6917
+ vertex,
6918
+ tips,
6919
+ lines
5919
6920
  };
5920
6921
  }
5921
- const GENERIC_RECTANGLE = defineControlMeasure({
5922
- metadata: GENERIC_RECTANGLE_METADATA,
5923
- generator: createGenericRectangle,
5924
- defaultOptions: DEFAULT_GENERIC_RECTANGLE_OPTIONS,
5925
- rule: rectangleDrawRule,
5926
- previewSample: { controlPoints: [
5927
- [-.9, -.55],
5928
- [.9, -.55],
5929
- [.9, .55]
5930
- ] }
5931
- });
5932
6922
  //#endregion
5933
- //#region src/generators/cm34-mission-tasks/clear.ts
6923
+ //#region src/generators/cm15-maneuver-areas/searchArea.ts
6924
+ const DEFAULT_ARROWHEAD_LENGTH_RATIO = .12;
6925
+ const DEFAULT_ARROWHEAD_WIDTH_RATIO = .5;
6926
+ const DEFAULT_ZIGZAG_START = .55;
6927
+ const DEFAULT_ZIGZAG_END = .45;
6928
+ const DEFAULT_ZIGZAG_AMPLITUDE_RATIO = .07;
5934
6929
  /**
5935
- * Default options for the CLEAR symbol.
6930
+ * Default options for the tactical arrow.
5936
6931
  */
5937
- const DEFAULT_CLEAR_OPTIONS = {
5938
- arrowInsetRatio: .15,
5939
- arrowheadLengthRatio: .15,
5940
- arrowheadWidthRatio: .2,
5941
- labelGapRatio: .2
6932
+ const DEFAULT_TACTICAL_ARROW_OPTIONS = {
6933
+ arrowheadLengthRatio: DEFAULT_ARROWHEAD_LENGTH_RATIO,
6934
+ arrowheadWidthRatio: DEFAULT_ARROWHEAD_WIDTH_RATIO,
6935
+ zigzagStart: DEFAULT_ZIGZAG_START,
6936
+ zigzagEnd: DEFAULT_ZIGZAG_END,
6937
+ zigzagAmplitudeRatio: DEFAULT_ZIGZAG_AMPLITUDE_RATIO
5942
6938
  };
5943
- const CLEAR_METADATA = {
5944
- id: "clear",
5945
- name: "Clear",
5946
- description: "Clear is a tactical mission task that requires a unit to remove all enemy forces and eliminate organized resistance within an assigned area.",
5947
- entity: "Mission Tasks",
5948
- entityType: "Clear",
5949
- value: "340500",
6939
+ const SEARCH_AREA_METADATA = {
6940
+ id: "search-area",
6941
+ name: "Search Area",
6942
+ description: "Search area graphic with paired lightning-style arrows.",
6943
+ entity: "Maneuver Areas",
6944
+ entityType: "Search Area",
6945
+ value: "152200",
5950
6946
  minCoordinates: 3,
5951
6947
  maxCoordinates: 3,
5952
- geometry: "line",
5953
- geometryTypes: ["MultiLineString", "Point"],
5954
- drawRule: "Line23",
5955
- capturesLabelSize: true,
6948
+ geometry: "area",
6949
+ geometryTypes: ["MultiLineString", "MultiPolygon"],
6950
+ drawRule: "Area21",
5956
6951
  params: [
5957
- {
5958
- key: "arrowInsetRatio",
5959
- label: "Arrow padding",
5960
- description: "Padding between the line endpoints and the outer arrows, as a ratio of height",
5961
- type: "number",
5962
- min: 0,
5963
- max: .45,
5964
- step: .01
5965
- },
5966
6952
  {
5967
6953
  key: "arrowheadLengthRatio",
5968
6954
  label: "Arrowhead length",
5969
- description: "Arrowhead length as a ratio of the symbol height",
6955
+ description: "Arrowhead length as a ratio of the total leg length",
5970
6956
  type: "number",
5971
- min: .01,
5972
- max: 1,
6957
+ min: 0,
6958
+ max: .5,
5973
6959
  step: .01
5974
6960
  },
5975
6961
  {
5976
6962
  key: "arrowheadWidthRatio",
5977
6963
  label: "Arrowhead width",
5978
- description: "Arrowhead base width as a ratio of the symbol height",
6964
+ description: "Arrowhead base width as a multiple of the arrowhead length",
5979
6965
  type: "number",
5980
- min: .01,
6966
+ min: .05,
6967
+ max: 2,
6968
+ step: .1
6969
+ },
6970
+ {
6971
+ key: "zigzagStart",
6972
+ label: "Zigzag start",
6973
+ description: "Point along the leg where the zigzag begins (0-1).",
6974
+ type: "number",
6975
+ min: 0,
5981
6976
  max: 1,
5982
- step: .01
6977
+ step: .05
5983
6978
  },
5984
6979
  {
5985
- key: "labelGapRatio",
5986
- label: "Label gap",
5987
- description: "Size of the shaft gap holding the 'C' label, as a ratio of the shaft length",
6980
+ key: "zigzagEnd",
6981
+ label: "Zigzag end",
6982
+ description: "Point along the leg where the zigzag returns (0-1).",
5988
6983
  type: "number",
5989
6984
  min: 0,
5990
6985
  max: 1,
6986
+ step: .05
6987
+ },
6988
+ {
6989
+ key: "zigzagAmplitudeRatio",
6990
+ label: "Zigzag amplitude",
6991
+ description: "Perpendicular kink offset as a ratio of leg length.",
6992
+ type: "number",
6993
+ min: 0,
6994
+ max: .3,
5991
6995
  step: .01
5992
6996
  }
5993
6997
  ]
5994
6998
  };
5995
6999
  /**
5996
- * Generates a GeoJSON FeatureCollection for a CLEAR mission task symbol (340500).
5997
- *
5998
- * A vertical line (PT. 1 → PT. 2) with three arrows pointing at it,
5999
- * perpendicular to the line. The arrows come from the rear — the side PT. 3
6000
- * sits on — and their tips touch the vertical line; each carries a shaft
6001
- * reaching back to the rear. The arrows are evenly spaced along the line, so
6002
- * the middle arrow's tip sits at the midpoint of the vertical line and its
6003
- * shaft carries the 'C' glyph. The arrows stay perpendicular to the vertical
6004
- * line at any rotation.
6005
- *
6006
- * Input Points:
6007
- * - PT. 1: Top endpoint of the vertical line
6008
- * - PT. 2: Bottom endpoint of the vertical line — defines height with PT. 1
6009
- * - PT. 3: Rear point — defines the shaft length (and which side the arrows
6010
- * come from)
7000
+ * Generates a GeoJSON FeatureCollection of a tactical "lightning" arrow.
7001
+ * Handles internal projection to maintain correct perpendicularity.
6011
7002
  *
6012
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
6013
- * @param options - Configuration options
6014
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
7003
+ * @param coordinates - [Apex Point (PT1), Top Tail (PT2), Bottom Tail (PT3)]
7004
+ * @param options - Geometric configuration parameters
7005
+ * @returns A FeatureCollection containing a MultiLineString for bolts and a MultiPolygon for heads
7006
+ */
7007
+ function createSearchArea(coordinates, options = {}) {
7008
+ const { arrowheadLengthRatio = DEFAULT_ARROWHEAD_LENGTH_RATIO, arrowheadWidthRatio = DEFAULT_ARROWHEAD_WIDTH_RATIO, zigzagStart = DEFAULT_ZIGZAG_START, zigzagEnd = DEFAULT_ZIGZAG_END, zigzagAmplitudeRatio = DEFAULT_ZIGZAG_AMPLITUDE_RATIO } = options;
7009
+ const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
7010
+ const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
7011
+ const safeZigzagStart = clamp01(zigzagStart);
7012
+ const safeZigzagEnd = clamp01(zigzagEnd);
7013
+ const safeZigzagAmplitudeRatio = Math.max(0, zigzagAmplitudeRatio);
7014
+ const [c0, c1, c2] = coordinates;
7015
+ const [p1, p2, p3] = [
7016
+ project(c0[0], c0[1]),
7017
+ project(c1[0], c1[1]),
7018
+ project(c2[0], c2[1])
7019
+ ];
7020
+ const shaftSegments = [];
7021
+ const headPolygons = [];
7022
+ const addLeg = (origin, tip, opposite) => {
7023
+ const v = vecSub(tip, origin);
7024
+ const totalLen = vecMag(v);
7025
+ if (totalLen < 1e-6) return;
7026
+ const u = vecNorm(v);
7027
+ let n = [-u[1], u[0]];
7028
+ const toOpp = vecSub(opposite, origin);
7029
+ if (vecDot(n, toOpp) > 0) n = vecScale(n, -1);
7030
+ const offset = totalLen * safeZigzagAmplitudeRatio;
7031
+ const ptA = vecAdd(vecAdd(origin, vecScale(v, safeZigzagStart)), vecScale(n, offset));
7032
+ const ptB = vecAdd(vecAdd(origin, vecScale(v, safeZigzagEnd)), vecScale(n, -offset));
7033
+ const fv = vecSub(tip, ptB);
7034
+ const fLen = vecMag(fv);
7035
+ let fu = vecNorm(fv);
7036
+ if (fLen < 1e-6) fu = u;
7037
+ const headLen = totalLen * safeArrowheadLengthRatio;
7038
+ const pBase = vecSub(tip, vecScale(fu, headLen));
7039
+ const wingWidth = headLen * safeArrowheadWidthRatio;
7040
+ const perp = [-fu[1], fu[0]];
7041
+ const pWing1 = vecAdd(pBase, vecScale(perp, wingWidth));
7042
+ const pWing2 = vecSub(pBase, vecScale(perp, wingWidth));
7043
+ shaftSegments.push([
7044
+ origin,
7045
+ ptA,
7046
+ ptB,
7047
+ pBase
7048
+ ].map((p) => unproject(p[0], p[1])));
7049
+ headPolygons.push([[
7050
+ tip,
7051
+ pWing1,
7052
+ pWing2,
7053
+ tip
7054
+ ].map((p) => unproject(p[0], p[1]))]);
7055
+ };
7056
+ addLeg(p1, p2, p3);
7057
+ addLeg(p1, p3, p2);
7058
+ const features = [];
7059
+ if (shaftSegments.length > 0) features.push({
7060
+ type: "Feature",
7061
+ properties: { part: "shaft" },
7062
+ geometry: {
7063
+ type: "MultiLineString",
7064
+ coordinates: shaftSegments
7065
+ }
7066
+ });
7067
+ if (headPolygons.length > 0) features.push({
7068
+ type: "Feature",
7069
+ properties: {
7070
+ part: "head",
7071
+ fill: true
7072
+ },
7073
+ geometry: {
7074
+ type: "MultiPolygon",
7075
+ coordinates: headPolygons
7076
+ }
7077
+ });
7078
+ return {
7079
+ type: "FeatureCollection",
7080
+ features
7081
+ };
7082
+ }
7083
+ const SEARCH_AREA = defineControlMeasure({
7084
+ metadata: SEARCH_AREA_METADATA,
7085
+ generator: (controlPoints, options) => createSearchArea(asThreePoints(controlPoints), options),
7086
+ defaultOptions: DEFAULT_TACTICAL_ARROW_OPTIONS,
7087
+ rule: searchAreaDrawRule
7088
+ });
7089
+ //#endregion
7090
+ //#region src/generators/cm34-mission-tasks/security-task-shared.ts
7091
+ const DEFAULT_SECURITY_TASK_OPTIONS = {
7092
+ ...DEFAULT_TACTICAL_ARROW_OPTIONS,
7093
+ labelInsetRatio: .2
7094
+ };
7095
+ const SECURITY_TASK_PARAMS = [
7096
+ {
7097
+ key: "arrowheadLengthRatio",
7098
+ label: "Arrowhead length",
7099
+ description: "Arrowhead length as a ratio of the total leg length",
7100
+ type: "number",
7101
+ min: 0,
7102
+ max: .5,
7103
+ step: .01
7104
+ },
7105
+ {
7106
+ key: "arrowheadWidthRatio",
7107
+ label: "Arrowhead width",
7108
+ description: "Arrowhead base width as a multiple of the arrowhead length",
7109
+ type: "number",
7110
+ min: .05,
7111
+ max: 2,
7112
+ step: .1
7113
+ },
7114
+ {
7115
+ key: "zigzagStart",
7116
+ label: "Zigzag start",
7117
+ description: "Point along the leg where the zigzag begins (0-1).",
7118
+ type: "number",
7119
+ min: 0,
7120
+ max: 1,
7121
+ step: .05
7122
+ },
7123
+ {
7124
+ key: "zigzagEnd",
7125
+ label: "Zigzag end",
7126
+ description: "Point along the leg where the zigzag returns (0-1).",
7127
+ type: "number",
7128
+ min: 0,
7129
+ max: 1,
7130
+ step: .05
7131
+ },
7132
+ {
7133
+ key: "zigzagAmplitudeRatio",
7134
+ label: "Zigzag amplitude",
7135
+ description: "Perpendicular kink offset as a ratio of leg length.",
7136
+ type: "number",
7137
+ min: 0,
7138
+ max: .3,
7139
+ step: .01
7140
+ },
7141
+ {
7142
+ key: "labelInsetRatio",
7143
+ label: "Letter position",
7144
+ description: "Inset of each task letter from PT. 2 or PT. 3 across the center opening",
7145
+ type: "number",
7146
+ min: .05,
7147
+ max: .45,
7148
+ step: .01
7149
+ }
7150
+ ];
7151
+ /**
7152
+ * Shared Line26 geometry for the Cover, Guard, and Screen task family.
7153
+ * PT. 1/PT. 4 are the outward tips; PT. 2/PT. 3 are the inner shaft ends.
7154
+ * The opening between PT. 2 and PT. 3 is deliberately left clear for the
7155
+ * supported tactical symbol, which is explanatory rather than part of this
7156
+ * control measure. The task glyph is repeated on either side of that opening.
6015
7157
  */
6016
- function createClearSymbol(coordinates, options = {}) {
6017
- const arrowInsetRatio = Math.min(.49, Math.max(0, options.arrowInsetRatio ?? DEFAULT_CLEAR_OPTIONS.arrowInsetRatio));
6018
- const arrowheadLengthRatio = options.arrowheadLengthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadLengthRatio;
6019
- const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadWidthRatio;
6020
- const labelGapRatio = options.labelGapRatio ?? DEFAULT_CLEAR_OPTIONS.labelGapRatio;
6021
- const [c1, c2, c3] = coordinates;
7158
+ function createSecurityTaskSymbol(coordinates, config, options = {}) {
7159
+ const { taskGlyph, part } = config;
7160
+ const [c1, c2, c3, c4] = coordinates;
6022
7161
  const p1 = project(c1[0], c1[1]);
6023
7162
  const p2 = project(c2[0], c2[1]);
6024
7163
  const p3 = project(c3[0], c3[1]);
6025
- const vLine = vecSub(p2, p1);
6026
- const height = vecMag(vLine);
6027
- if (height < 1e-6) return {
7164
+ const p4 = project(c4[0], c4[1]);
7165
+ const resolved = {
7166
+ ...DEFAULT_SECURITY_TASK_OPTIONS,
7167
+ ...options
7168
+ };
7169
+ const arrowOptions = {
7170
+ arrowheadLengthRatio: Math.max(0, resolved.arrowheadLengthRatio),
7171
+ arrowheadWidthRatio: Math.max(0, resolved.arrowheadWidthRatio),
7172
+ zigzagStart: clamp(resolved.zigzagStart, 0, 1),
7173
+ zigzagEnd: clamp(resolved.zigzagEnd, 0, 1),
7174
+ zigzagAmplitudeRatio: Math.max(0, resolved.zigzagAmplitudeRatio)
7175
+ };
7176
+ const lines = [createTacticalArrow(p2, p1, p3, arrowOptions), createTacticalArrow(p3, p4, p2, arrowOptions)].flatMap((arrow) => arrow ?? []);
7177
+ if (lines.length === 0) return {
6028
7178
  type: "FeatureCollection",
6029
7179
  features: []
6030
7180
  };
6031
- const dirLine = vecScale(vLine, 1 / height);
6032
- const midLine = vecScale(vecAdd(p1, p2), .5);
6033
- const rawPerp = [-dirLine[1], dirLine[0]];
6034
- const perpToRear = vecScale(rawPerp, vecDot(vecSub(p3, midLine), rawPerp) < 0 ? -1 : 1);
6035
- const shaftLength = Math.abs(vecDot(vecSub(p3, midLine), rawPerp));
6036
- const arrowDir = vecScale(perpToRear, -1);
6037
- const arrowheadLength = height * arrowheadLengthRatio;
6038
- const arrowheadWidth = height * arrowheadWidthRatio;
6039
- const tipAt = (t) => vecAdd(p1, vecScale(dirLine, t * height));
6040
- const lines = [[unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]];
6041
- const fullTs = [
6042
- arrowInsetRatio,
6043
- .5,
6044
- 1 - arrowInsetRatio
6045
- ];
6046
- const labelIndex = 1;
6047
- fullTs.forEach((t, i) => {
6048
- const tip = tipAt(t);
6049
- const rearEnd = vecAdd(tip, vecScale(perpToRear, shaftLength));
6050
- if (i === labelIndex && shaftLength > 1e-6) {
6051
- const labelCenter = vecAdd(tip, vecScale(perpToRear, shaftLength * .5));
6052
- const gapHalf = shaftLength * labelGapRatio / 2;
6053
- const gapRear = vecAdd(labelCenter, vecScale(perpToRear, gapHalf));
6054
- const gapTip = vecAdd(labelCenter, vecScale(perpToRear, -gapHalf));
6055
- lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
6056
- lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
6057
- } else if (shaftLength > 1e-6) lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(tip[0], tip[1])]);
6058
- lines.push(createArrowHead$1(tip, arrowDir, arrowheadLength, arrowheadWidth));
6059
- });
6060
- const mainFeature = {
7181
+ const features = [{
6061
7182
  type: "Feature",
6062
- properties: { part: "clear" },
7183
+ properties: { part },
6063
7184
  geometry: {
6064
7185
  type: "MultiLineString",
6065
7186
  coordinates: lines
6066
7187
  }
6067
- };
6068
- const lineRotation = Math.atan2(dirLine[1], dirLine[0]);
6069
- const renderedLabelRotation = Math.PI - (lineRotation - Math.PI / 2);
6070
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
6071
- const labelTip = tipAt(fullTs[labelIndex]);
6072
- return {
6073
- type: "FeatureCollection",
6074
- features: [mainFeature, createLabelFeature(shaftLength > 1e-6 ? vecAdd(labelTip, vecScale(perpToRear, shaftLength * .5)) : labelTip, "C", labelRotation, {
7188
+ }];
7189
+ const gap = vecSub(p3, p2);
7190
+ if (vecMag(gap) >= 1e-6) {
7191
+ const gapDir = vecNorm(gap);
7192
+ const inset = clamp(resolved.labelInsetRatio, .05, .45);
7193
+ const rotation = labelRotationAlong(gapDir);
7194
+ const size = {
6075
7195
  labelSize: options.labelSize,
6076
7196
  labelSizePixels: options.labelSizePixels
6077
- })]
7197
+ };
7198
+ features.push(createLabelFeature(vecAdd(p2, vecScale(gap, inset)), taskGlyph, rotation, size), createLabelFeature(vecAdd(p3, vecScale(gap, -inset)), taskGlyph, rotation, size));
7199
+ }
7200
+ return {
7201
+ type: "FeatureCollection",
7202
+ features
6078
7203
  };
6079
7204
  }
6080
- const CLEAR = defineControlMeasure({
6081
- metadata: CLEAR_METADATA,
6082
- generator: createClearSymbol,
6083
- defaultOptions: DEFAULT_CLEAR_OPTIONS,
6084
- rule: line23DrawRule
7205
+ /** A Search Area-style lightning shaft with a Principal Direction of Fire open head. */
7206
+ function createTacticalArrow(origin, tip, opposite, options) {
7207
+ const leg = vecSub(tip, origin);
7208
+ const totalLegLength = vecMag(leg);
7209
+ if (totalLegLength < 1e-6) return void 0;
7210
+ const legDirection = vecNorm(leg);
7211
+ let normal = [-legDirection[1], legDirection[0]];
7212
+ if (vecDot(normal, vecSub(opposite, origin)) > 0) normal = vecScale(normal, -1);
7213
+ const amplitude = totalLegLength * options.zigzagAmplitudeRatio;
7214
+ const zigzagPointA = vecAdd(vecAdd(origin, vecScale(leg, options.zigzagStart)), vecScale(normal, amplitude));
7215
+ const zigzagPointB = vecAdd(vecAdd(origin, vecScale(leg, options.zigzagEnd)), vecScale(normal, -amplitude));
7216
+ const arrowhead = arrowheadAt(origin, tip, options.arrowheadLengthRatio, options.arrowheadWidthRatio);
7217
+ if (!arrowhead) return void 0;
7218
+ return [[
7219
+ origin,
7220
+ zigzagPointA,
7221
+ zigzagPointB,
7222
+ tip
7223
+ ], arrowhead].map((line) => line.map((point) => unproject(point[0], point[1])));
7224
+ }
7225
+ //#endregion
7226
+ //#region src/generators/cm34-mission-tasks/cover.ts
7227
+ const DEFAULT_COVER_OPTIONS = DEFAULT_SECURITY_TASK_OPTIONS;
7228
+ const COVER_METADATA = {
7229
+ id: "cover",
7230
+ name: "Cover",
7231
+ description: "Protects the main body by fighting to gain time while observing and reporting information and preventing enemy ground observation of and direct fire against the main body; unlike a screen or guard, a covering force is tactically self-contained and operates independently of the main body.",
7232
+ entity: "Mission Tasks",
7233
+ entityType: "Security",
7234
+ entitySubtype: "Cover",
7235
+ value: "342201",
7236
+ minCoordinates: 4,
7237
+ maxCoordinates: 4,
7238
+ geometry: "line",
7239
+ geometryTypes: ["MultiLineString", "Point"],
7240
+ drawRule: "Line26",
7241
+ capturesLabelSize: true,
7242
+ params: SECURITY_TASK_PARAMS
7243
+ };
7244
+ function createCoverSymbol(coordinates, options = {}) {
7245
+ return createSecurityTaskSymbol(coordinates, {
7246
+ taskGlyph: "C",
7247
+ part: "cover"
7248
+ }, options);
7249
+ }
7250
+ const COVER = defineControlMeasure({
7251
+ metadata: COVER_METADATA,
7252
+ generator: createCoverSymbol,
7253
+ defaultOptions: DEFAULT_COVER_OPTIONS,
7254
+ rule: line26DrawRule,
7255
+ previewSample: {
7256
+ controlPoints: [
7257
+ [-1, 0],
7258
+ [-.3, 0],
7259
+ [.3, 0],
7260
+ [1, 0]
7261
+ ],
7262
+ options: { labelSize: 90 }
7263
+ }
6085
7264
  });
6086
7265
  //#endregion
6087
7266
  //#region src/generators/cm34-mission-tasks/delay.ts
@@ -6094,7 +7273,7 @@ const DEFAULT_DELAY_OPTIONS = {
6094
7273
  const DELAY_METADATA = {
6095
7274
  id: "delay",
6096
7275
  name: "Delay",
6097
- description: "Delay is a tactical mission task in which a force under pressure trades space for time by slowing enemy momentum and inflicting maximum damage without becoming decisively engaged.",
7276
+ description: "Trades space for time while under pressure, slowing the enemy's momentum and inflicting maximum damage without becoming decisively engaged.",
6098
7277
  entity: "Mission Tasks",
6099
7278
  entityType: "Delay",
6100
7279
  value: "340800",
@@ -6209,6 +7388,46 @@ const DELAY = defineControlMeasure({
6209
7388
  rule: line24DrawRule
6210
7389
  });
6211
7390
  //#endregion
7391
+ //#region src/generators/cm34-mission-tasks/guard.ts
7392
+ const DEFAULT_GUARD_OPTIONS = DEFAULT_SECURITY_TASK_OPTIONS;
7393
+ const GUARD_METADATA = {
7394
+ id: "guard",
7395
+ name: "Guard",
7396
+ description: "Protects the main body by fighting to gain time while observing and reporting information and preventing enemy ground observation of and direct fire against the main body; a guard force operates within supporting range of the main body and cannot operate independently of it.",
7397
+ entity: "Mission Tasks",
7398
+ entityType: "Security",
7399
+ entitySubtype: "Guard",
7400
+ value: "342202",
7401
+ minCoordinates: 4,
7402
+ maxCoordinates: 4,
7403
+ geometry: "line",
7404
+ geometryTypes: ["MultiLineString", "Point"],
7405
+ drawRule: "Line26",
7406
+ capturesLabelSize: true,
7407
+ params: SECURITY_TASK_PARAMS
7408
+ };
7409
+ function createGuardSymbol(coordinates, options = {}) {
7410
+ return createSecurityTaskSymbol(coordinates, {
7411
+ taskGlyph: "G",
7412
+ part: "guard"
7413
+ }, options);
7414
+ }
7415
+ const GUARD = defineControlMeasure({
7416
+ metadata: GUARD_METADATA,
7417
+ generator: createGuardSymbol,
7418
+ defaultOptions: DEFAULT_GUARD_OPTIONS,
7419
+ rule: line26DrawRule,
7420
+ previewSample: {
7421
+ controlPoints: [
7422
+ [-1, 0],
7423
+ [-.3, 0],
7424
+ [.3, 0],
7425
+ [1, 0]
7426
+ ],
7427
+ options: { labelSize: 90 }
7428
+ }
7429
+ });
7430
+ //#endregion
6212
7431
  //#region src/internal/arrowhead.ts
6213
7432
  /**
6214
7433
  * Builds a closed, filled arrowhead triangle ring in unprojected coordinates.
@@ -6671,55 +7890,6 @@ const EXTRACTION_ZONE = defineControlMeasure({
6671
7890
  }
6672
7891
  });
6673
7892
  //#endregion
6674
- //#region src/internal/field-of-fire.ts
6675
- /**
6676
- * Builds the open (stroked) arrowhead at the outward tip of one arm. The two
6677
- * barbs are sized relative to that arm's own length, so each arm's head scales
6678
- * independently (Line3 — arms vary independently). Returns `null` when the arm
6679
- * has collapsed to zero length.
6680
- */
6681
- function arrowheadAt(vertex, tip, hSize, hWidth) {
6682
- const v = vecSub(tip, vertex);
6683
- const armLength = vecMag(v);
6684
- if (armLength < 1e-6) return null;
6685
- const dir = vecNorm(v);
6686
- const perp = [-dir[1], dir[0]];
6687
- const headLength = armLength * hSize;
6688
- const halfWidth = headLength * hWidth;
6689
- const base = vecSub(tip, vecScale(dir, headLength));
6690
- return [
6691
- vecAdd(base, vecScale(perp, halfWidth)),
6692
- tip,
6693
- vecSub(base, vecScale(perp, halfWidth))
6694
- ];
6695
- }
6696
- /**
6697
- * Projects the vertex (PT 1) and up to two tips and builds the V of arms, each
6698
- * arm ending in an open arrowhead. Renders one arm per tip, so two points
6699
- * (vertex + one tip) draws the single arm being placed — the live feedback
6700
- * after PT 1 — and three points draws the full V. Points beyond the third are
6701
- * ignored (input contract, ADR-0014).
6702
- *
6703
- * Returns `null` if any arm is degenerate (collapsed to zero length), the
6704
- * silent ADR-0014 empty-render signal.
6705
- */
6706
- function buildFieldOfFireV(coordinates, hSize, hWidth) {
6707
- const vertexCoord = coordinates[0];
6708
- const vertex = project(vertexCoord[0], vertexCoord[1]);
6709
- const tips = coordinates.slice(1, 3).map((c) => project(c[0], c[1]));
6710
- const lines = [];
6711
- for (const tip of tips) {
6712
- const head = arrowheadAt(vertex, tip, hSize, hWidth);
6713
- if (!head) return null;
6714
- lines.push([vertex, tip], head);
6715
- }
6716
- return {
6717
- vertex,
6718
- tips,
6719
- lines
6720
- };
6721
- }
6722
- //#endregion
6723
7893
  //#region src/generators/cm14-maneuver-lines/finalProtectiveFire.ts
6724
7894
  const BLADE_LENGTH_FRACTION = .8;
6725
7895
  const DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS = {
@@ -7177,10 +8347,12 @@ function createFLOT(positions, options = {}) {
7177
8347
  if (radiusPixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveRadius = radiusPixels * metersPerPixel;
7178
8348
  const safeRadius = Math.max(EPSILON, effectiveRadius);
7179
8349
  const safeArcSegments = Math.max(3, Math.round(arcSegments));
7180
- const basePath = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
8350
+ const projectedPoints = positions.map((p) => project(p[0], p[1]));
8351
+ const smoothedPath = smoothLineVerts(projectedPoints, {
7181
8352
  smooth,
7182
8353
  smoothResolution
7183
8354
  }, 16);
8355
+ const basePath = smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, 2 * safeRadius) : smoothedPath;
7184
8356
  const allPoints = [];
7185
8357
  for (let i = 0; i < basePath.length - 1; i++) {
7186
8358
  const p1 = basePath[i];
@@ -7571,7 +8743,7 @@ function createFortifiedLine(positions, options = {}) {
7571
8743
  properties: {},
7572
8744
  geometry: {
7573
8745
  type: "LineString",
7574
- coordinates: generateFortifiedPoints(smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints, safeSize).map((p) => unproject(p[0], p[1]))
8746
+ coordinates: generateFortifiedPoints(smooth ? evenlySpacePath(catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)), 2 * safeSize) : projectedPoints, safeSize).map((p) => unproject(p[0], p[1]))
7575
8747
  }
7576
8748
  }]
7577
8749
  };
@@ -7623,7 +8795,7 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
7623
8795
  const safeSize = calculateEffectiveSize(options);
7624
8796
  const projected = positions.map((p) => project(p[0], p[1]));
7625
8797
  const ringPts = projected.length > 1 && vecMag(vecSub(projected[0], projected[projected.length - 1])) < 1e-6 ? projected.slice(0, -1) : projected;
7626
- const boundaryInput = smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)) : [...ringPts, ringPts[0]];
8798
+ const boundaryInput = smooth ? evenlySpacePath(closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)), 2 * safeSize) : [...ringPts, ringPts[0]];
7627
8799
  const boundaryPoints = generateFortifiedPoints(boundaryInput, safeSize);
7628
8800
  if (boundaryPoints.length > 0) {
7629
8801
  const firstPt = boundaryPoints[0];
@@ -7631,41 +8803,235 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
7631
8803
  if (vecMag(vecSub(firstPt, lastPt)) > 1e-6) boundaryPoints.push(firstPt);
7632
8804
  else boundaryPoints[boundaryPoints.length - 1] = firstPt;
7633
8805
  }
7634
- const features = [{
7635
- type: "Feature",
7636
- properties: {},
7637
- geometry: {
7638
- type: "Polygon",
7639
- coordinates: [boundaryPoints.map((p) => unproject(p[0], p[1]))]
7640
- }
7641
- }];
7642
- const labelFeatures = [];
7643
- pushAreaLabels(labelFeatures, boundaryInput, {
7644
- name: textAmplifiers.T,
7645
- additionalInformation: textAmplifiers.H,
7646
- dtgStart: textAmplifiers.W,
7647
- dtgEnd: textAmplifiers.W1
7648
- }, options);
7649
- features.push(...labelFeatures);
7650
- return {
7651
- type: "FeatureCollection",
7652
- features
7653
- };
8806
+ const features = [{
8807
+ type: "Feature",
8808
+ properties: {},
8809
+ geometry: {
8810
+ type: "Polygon",
8811
+ coordinates: [boundaryPoints.map((p) => unproject(p[0], p[1]))]
8812
+ }
8813
+ }];
8814
+ const labelFeatures = [];
8815
+ pushAreaLabels(labelFeatures, boundaryInput, {
8816
+ name: textAmplifiers.T,
8817
+ additionalInformation: textAmplifiers.H,
8818
+ dtgStart: textAmplifiers.W,
8819
+ dtgEnd: textAmplifiers.W1
8820
+ }, options);
8821
+ features.push(...labelFeatures);
8822
+ return {
8823
+ type: "FeatureCollection",
8824
+ features
8825
+ };
8826
+ }
8827
+ const FORTIFIED_AREA = defineControlMeasure({
8828
+ metadata: FORTIFIED_AREA_METADATA,
8829
+ generator: createFortifiedArea,
8830
+ defaultOptions: DEFAULT_FORTIFIED_AREA_OPTIONS,
8831
+ rule: area1DrawRule,
8832
+ previewSample: {
8833
+ controlPoints: [
8834
+ [-1, -.5],
8835
+ [1, -.5],
8836
+ [1, .5],
8837
+ [-1, .5]
8838
+ ],
8839
+ textAmplifiers: { T: "TANGO" },
8840
+ options: { labelSize: 96 }
8841
+ }
8842
+ });
8843
+ //#endregion
8844
+ //#region src/generators/cm15-maneuver-areas/maneuver-arrow-task-shared.ts
8845
+ const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
8846
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
8847
+ smooth: false,
8848
+ smoothResolution: 5,
8849
+ crossbarLengthRatio: 1.1,
8850
+ labelPosition: .25,
8851
+ dtgPosition: .62,
8852
+ labelPadding: 0
8853
+ };
8854
+ function midpoint(a, b) {
8855
+ return vecScale(vecAdd(a, b), .5);
8856
+ }
8857
+ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
8858
+ const resolved = {
8859
+ ...DEFAULT_MANEUVER_ARROW_TASK_OPTIONS,
8860
+ ...options
8861
+ };
8862
+ const { geometry } = processAttackGeometry(coordinates, resolved);
8863
+ if (!geometry) return {
8864
+ type: "FeatureCollection",
8865
+ features: []
8866
+ };
8867
+ const outline = [
8868
+ ...geometry.shaftLeft,
8869
+ geometry.headRing[0],
8870
+ geometry.headRing[1],
8871
+ geometry.headRing[2],
8872
+ ...[...geometry.shaftRight].reverse()
8873
+ ];
8874
+ const centerline = geometry.shaftLeft.map((left, index) => midpoint(left, geometry.shaftRight[index]));
8875
+ const { segments, totalLength } = polylineSegments(centerline);
8876
+ if (segments.length === 0) return {
8877
+ type: "FeatureCollection",
8878
+ features: []
8879
+ };
8880
+ const tipVector = vecSub(geometry.ptTip, geometry.ptBase);
8881
+ const tipDirection = vecNorm(tipVector);
8882
+ const crossbarLength = vecMag(vecSub(geometry.headRing[0], geometry.headRing[2])) * Math.max(1, resolved.crossbarLengthRatio);
8883
+ let crossbarCenter;
8884
+ let crossbarAlong;
8885
+ if (config.crossbarAt === "tip") {
8886
+ crossbarCenter = geometry.ptTip;
8887
+ crossbarAlong = tipDirection;
8888
+ } else {
8889
+ crossbarCenter = centerline[0];
8890
+ crossbarAlong = segments[0].along;
8891
+ }
8892
+ const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
8893
+ const crossbar = [vecAdd(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2)), vecSub(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2))];
8894
+ const features = [{
8895
+ type: "Feature",
8896
+ properties: { part: config.part },
8897
+ geometry: {
8898
+ type: "MultiLineString",
8899
+ coordinates: [outline, crossbar].map((line) => line.map((point) => unproject(point[0], point[1])))
8900
+ }
8901
+ }];
8902
+ const sizeProps = labelSizeProps(resolved);
8903
+ const codePoint = vecAdd(geometry.ptBase, vecScale(tipVector, .55));
8904
+ const labels = [{
8905
+ type: "Feature",
8906
+ properties: {
8907
+ part: "label",
8908
+ labelPlacement: false,
8909
+ text: config.code,
8910
+ rotation: labelRotationAlong(tipDirection),
8911
+ ...sizeProps
8912
+ },
8913
+ geometry: {
8914
+ type: "Point",
8915
+ coordinates: unproject(codePoint[0], codePoint[1])
8916
+ }
8917
+ }];
8918
+ const labelFrame = pointAlongPolyline(segments, totalLength, clamp01(resolved.labelPosition));
8919
+ if (labelFrame && textAmplifiers.T) pushLabel(labels, labelFrame.point, textAmplifiers.T, labelRotationAlong(labelFrame.along), sizeProps, void 0, "T");
8920
+ const dtgFrame = pointAlongPolyline(segments, totalLength, clamp01(resolved.dtgPosition));
8921
+ if (dtgFrame) {
8922
+ const rowOffset = resolveLabelOffsetMeters(resolved, .55 + Math.max(0, resolved.labelPadding));
8923
+ const rotation = labelRotationAlong(dtgFrame.along);
8924
+ if (textAmplifiers.W) pushLabel(labels, vecAdd(dtgFrame.point, vecScale(dtgFrame.perp, rowOffset)), textAmplifiers.W, rotation, sizeProps, void 0, "W");
8925
+ if (textAmplifiers.W1) pushLabel(labels, vecSub(dtgFrame.point, vecScale(dtgFrame.perp, rowOffset)), textAmplifiers.W1, rotation, sizeProps, void 0, "W1");
8926
+ }
8927
+ features.push(...labels);
8928
+ return {
8929
+ type: "FeatureCollection",
8930
+ features
8931
+ };
8932
+ }
8933
+ const MANEUVER_ARROW_TASK_PARAMS = [
8934
+ {
8935
+ key: "crossbarLengthRatio",
8936
+ label: "Straight line length",
8937
+ description: "Crossbar length as a multiple of the arrowhead base width",
8938
+ type: "number",
8939
+ min: 1,
8940
+ max: 2.5,
8941
+ step: .1
8942
+ },
8943
+ {
8944
+ key: "labelPosition",
8945
+ label: "Designation position",
8946
+ description: "Position of Field T along the shaft, from tail (0) to head (1)",
8947
+ type: "number",
8948
+ min: 0,
8949
+ max: 1,
8950
+ step: .01
8951
+ },
8952
+ {
8953
+ key: "dtgPosition",
8954
+ label: "Date-time position",
8955
+ description: "Position of Fields W/W1 along the shaft, from tail (0) to head (1)",
8956
+ type: "number",
8957
+ min: 0,
8958
+ max: 1,
8959
+ step: .01
8960
+ },
8961
+ {
8962
+ key: "labelPadding",
8963
+ label: "Label padding",
8964
+ description: "Extra spacing between the W and W1 rows, in label heights",
8965
+ type: "number",
8966
+ min: 0,
8967
+ max: 2,
8968
+ step: .05
8969
+ }
8970
+ ];
8971
+ const MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS = [
8972
+ {
8973
+ key: "T",
8974
+ label: "Unique designation",
8975
+ description: "Field T — the maneuver's unique designation.",
8976
+ placeholder: "DAVID",
8977
+ maxLength: 21
8978
+ },
8979
+ {
8980
+ key: "W",
8981
+ label: "Start date-time group",
8982
+ description: "Field W — the maneuver's start date-time group.",
8983
+ placeholder: "080400ZOCT2008"
8984
+ },
8985
+ {
8986
+ key: "W1",
8987
+ label: "End date-time group",
8988
+ description: "Field W1 — the maneuver's end date-time group.",
8989
+ placeholder: "120300ZOCT2008"
8990
+ }
8991
+ ];
8992
+ //#endregion
8993
+ //#region src/generators/cm15-maneuver-areas/frontal-attack.ts
8994
+ const DEFAULT_FRONTAL_ATTACK_OPTIONS = DEFAULT_MANEUVER_ARROW_TASK_OPTIONS;
8995
+ const FRONTAL_ATTACK_METADATA = {
8996
+ id: "frontal-attack",
8997
+ name: "Frontal Attack",
8998
+ description: "An offensive maneuver in which the main action is directed against the front.",
8999
+ entity: "Maneuver Areas",
9000
+ entityType: "Frontal Attack",
9001
+ value: "152700",
9002
+ minCoordinates: 3,
9003
+ geometry: "line",
9004
+ geometryTypes: ["MultiLineString", "Point"],
9005
+ drawRule: "Axis1",
9006
+ capturesLabelSize: true,
9007
+ params: [...ATTACK_SHAFT_PARAMS, ...MANEUVER_ARROW_TASK_PARAMS],
9008
+ textAmplifiers: MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS
9009
+ };
9010
+ function createFrontalAttack(coordinates, options = {}, textAmplifiers = {}) {
9011
+ return createManeuverArrowTask(coordinates, options, textAmplifiers, {
9012
+ part: "frontal-attack",
9013
+ code: "A",
9014
+ crossbarAt: "tip"
9015
+ });
7654
9016
  }
7655
- const FORTIFIED_AREA = defineControlMeasure({
7656
- metadata: FORTIFIED_AREA_METADATA,
7657
- generator: createFortifiedArea,
7658
- defaultOptions: DEFAULT_FORTIFIED_AREA_OPTIONS,
7659
- rule: area1DrawRule,
9017
+ const FRONTAL_ATTACK = defineControlMeasure({
9018
+ metadata: FRONTAL_ATTACK_METADATA,
9019
+ generator: createFrontalAttack,
9020
+ defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
9021
+ rule: axis1DrawRule,
7660
9022
  previewSample: {
7661
9023
  controlPoints: [
7662
- [-1, -.5],
7663
- [1, -.5],
7664
- [1, .5],
7665
- [-1, .5]
9024
+ [1, 0],
9025
+ [-.4, -.35],
9026
+ [-1, -.8],
9027
+ [.45, .55]
7666
9028
  ],
7667
- textAmplifiers: { T: "TANGO" },
7668
- options: { labelSize: 96 }
9029
+ textAmplifiers: {
9030
+ T: "FOX",
9031
+ W: "080400Z",
9032
+ W1: "120300Z"
9033
+ },
9034
+ options: { labelSize: 80 }
7669
9035
  }
7670
9036
  });
7671
9037
  //#endregion
@@ -7681,7 +9047,7 @@ const DEFAULT_ISOLATE_OPTIONS = {
7681
9047
  const ISOLATE_METADATA = {
7682
9048
  id: "isolate",
7683
9049
  name: "Isolate",
7684
- description: "Isolate is a tactical mission task in which a unit seals off an enemy, physically and psychologically, from sources of support and denies it freedom of movement.",
9050
+ description: "Seals off an enemy, physically and psychologically, from sources of support, denies it freedom of movement, and prevents it from having contact with other enemy forces.",
7685
9051
  entity: "Mission Tasks",
7686
9052
  entityType: "Isolate",
7687
9053
  value: "341500",
@@ -7799,6 +9165,46 @@ const ISOLATE = defineControlMeasure({
7799
9165
  rule: isolateDrawRule
7800
9166
  });
7801
9167
  //#endregion
9168
+ //#region src/generators/cm34-mission-tasks/screen.ts
9169
+ const DEFAULT_SCREEN_OPTIONS = DEFAULT_SECURITY_TASK_OPTIONS;
9170
+ const SCREEN_METADATA = {
9171
+ id: "screen",
9172
+ name: "Screen",
9173
+ description: "Provides early warning to the protected force through observation, identification, reporting, and continuous surveillance of the enemy.",
9174
+ entity: "Mission Tasks",
9175
+ entityType: "Security",
9176
+ entitySubtype: "Screen",
9177
+ value: "342203",
9178
+ minCoordinates: 4,
9179
+ maxCoordinates: 4,
9180
+ geometry: "line",
9181
+ geometryTypes: ["MultiLineString", "Point"],
9182
+ drawRule: "Line26",
9183
+ capturesLabelSize: true,
9184
+ params: SECURITY_TASK_PARAMS
9185
+ };
9186
+ function createScreenSymbol(coordinates, options = {}) {
9187
+ return createSecurityTaskSymbol(coordinates, {
9188
+ taskGlyph: "S",
9189
+ part: "screen"
9190
+ }, options);
9191
+ }
9192
+ const SCREEN = defineControlMeasure({
9193
+ metadata: SCREEN_METADATA,
9194
+ generator: createScreenSymbol,
9195
+ defaultOptions: DEFAULT_SCREEN_OPTIONS,
9196
+ rule: line26DrawRule,
9197
+ previewSample: {
9198
+ controlPoints: [
9199
+ [-1, 0],
9200
+ [-.3, 0],
9201
+ [.3, 0],
9202
+ [1, 0]
9203
+ ],
9204
+ options: { labelSize: 90 }
9205
+ }
9206
+ });
9207
+ //#endregion
7802
9208
  //#region src/generators/cm15-maneuver-areas/jointTacticalActionArea.ts
7803
9209
  /** Default options for the Joint tactical action area control measure. */
7804
9210
  const DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS = {
@@ -8645,173 +10051,6 @@ const PRINCIPAL_DIRECTION_OF_FIRE = defineControlMeasure({
8645
10051
  ] }
8646
10052
  });
8647
10053
  //#endregion
8648
- //#region src/generators/cm15-maneuver-areas/searchArea.ts
8649
- const DEFAULT_ARROWHEAD_LENGTH_RATIO = .12;
8650
- const DEFAULT_ARROWHEAD_WIDTH_RATIO = .5;
8651
- const DEFAULT_ZIGZAG_START = .55;
8652
- const DEFAULT_ZIGZAG_END = .45;
8653
- const DEFAULT_ZIGZAG_AMPLITUDE_RATIO = .07;
8654
- /**
8655
- * Default options for the tactical arrow.
8656
- */
8657
- const DEFAULT_TACTICAL_ARROW_OPTIONS = {
8658
- arrowheadLengthRatio: DEFAULT_ARROWHEAD_LENGTH_RATIO,
8659
- arrowheadWidthRatio: DEFAULT_ARROWHEAD_WIDTH_RATIO,
8660
- zigzagStart: DEFAULT_ZIGZAG_START,
8661
- zigzagEnd: DEFAULT_ZIGZAG_END,
8662
- zigzagAmplitudeRatio: DEFAULT_ZIGZAG_AMPLITUDE_RATIO
8663
- };
8664
- const SEARCH_AREA_METADATA = {
8665
- id: "search-area",
8666
- name: "Search Area",
8667
- description: "Search area graphic with paired lightning-style arrows.",
8668
- entity: "Maneuver Areas",
8669
- entityType: "Search Area",
8670
- value: "152200",
8671
- minCoordinates: 3,
8672
- maxCoordinates: 3,
8673
- geometry: "area",
8674
- geometryTypes: ["MultiLineString", "MultiPolygon"],
8675
- drawRule: "Area21",
8676
- params: [
8677
- {
8678
- key: "arrowheadLengthRatio",
8679
- label: "Arrowhead length",
8680
- description: "Arrowhead length as a ratio of the total leg length",
8681
- type: "number",
8682
- min: 0,
8683
- max: .5,
8684
- step: .01
8685
- },
8686
- {
8687
- key: "arrowheadWidthRatio",
8688
- label: "Arrowhead width",
8689
- description: "Arrowhead base width as a multiple of the arrowhead length",
8690
- type: "number",
8691
- min: .05,
8692
- max: 2,
8693
- step: .1
8694
- },
8695
- {
8696
- key: "zigzagStart",
8697
- label: "Zigzag start",
8698
- description: "Point along the leg where the zigzag begins (0-1).",
8699
- type: "number",
8700
- min: 0,
8701
- max: 1,
8702
- step: .05
8703
- },
8704
- {
8705
- key: "zigzagEnd",
8706
- label: "Zigzag end",
8707
- description: "Point along the leg where the zigzag returns (0-1).",
8708
- type: "number",
8709
- min: 0,
8710
- max: 1,
8711
- step: .05
8712
- },
8713
- {
8714
- key: "zigzagAmplitudeRatio",
8715
- label: "Zigzag amplitude",
8716
- description: "Perpendicular kink offset as a ratio of leg length.",
8717
- type: "number",
8718
- min: 0,
8719
- max: .3,
8720
- step: .01
8721
- }
8722
- ]
8723
- };
8724
- /**
8725
- * Generates a GeoJSON FeatureCollection of a tactical "lightning" arrow.
8726
- * Handles internal projection to maintain correct perpendicularity.
8727
- *
8728
- * @param coordinates - [Apex Point (PT1), Top Tail (PT2), Bottom Tail (PT3)]
8729
- * @param options - Geometric configuration parameters
8730
- * @returns A FeatureCollection containing a MultiLineString for bolts and a MultiPolygon for heads
8731
- */
8732
- function createSearchArea(coordinates, options = {}) {
8733
- const { arrowheadLengthRatio = DEFAULT_ARROWHEAD_LENGTH_RATIO, arrowheadWidthRatio = DEFAULT_ARROWHEAD_WIDTH_RATIO, zigzagStart = DEFAULT_ZIGZAG_START, zigzagEnd = DEFAULT_ZIGZAG_END, zigzagAmplitudeRatio = DEFAULT_ZIGZAG_AMPLITUDE_RATIO } = options;
8734
- const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
8735
- const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
8736
- const safeZigzagStart = clamp01(zigzagStart);
8737
- const safeZigzagEnd = clamp01(zigzagEnd);
8738
- const safeZigzagAmplitudeRatio = Math.max(0, zigzagAmplitudeRatio);
8739
- const [c0, c1, c2] = coordinates;
8740
- const [p1, p2, p3] = [
8741
- project(c0[0], c0[1]),
8742
- project(c1[0], c1[1]),
8743
- project(c2[0], c2[1])
8744
- ];
8745
- const shaftSegments = [];
8746
- const headPolygons = [];
8747
- const addLeg = (origin, tip, opposite) => {
8748
- const v = vecSub(tip, origin);
8749
- const totalLen = vecMag(v);
8750
- if (totalLen < 1e-6) return;
8751
- const u = vecNorm(v);
8752
- let n = [-u[1], u[0]];
8753
- const toOpp = vecSub(opposite, origin);
8754
- if (vecDot(n, toOpp) > 0) n = vecScale(n, -1);
8755
- const offset = totalLen * safeZigzagAmplitudeRatio;
8756
- const ptA = vecAdd(vecAdd(origin, vecScale(v, safeZigzagStart)), vecScale(n, offset));
8757
- const ptB = vecAdd(vecAdd(origin, vecScale(v, safeZigzagEnd)), vecScale(n, -offset));
8758
- const fv = vecSub(tip, ptB);
8759
- const fLen = vecMag(fv);
8760
- let fu = vecNorm(fv);
8761
- if (fLen < 1e-6) fu = u;
8762
- const headLen = totalLen * safeArrowheadLengthRatio;
8763
- const pBase = vecSub(tip, vecScale(fu, headLen));
8764
- const wingWidth = headLen * safeArrowheadWidthRatio;
8765
- const perp = [-fu[1], fu[0]];
8766
- const pWing1 = vecAdd(pBase, vecScale(perp, wingWidth));
8767
- const pWing2 = vecSub(pBase, vecScale(perp, wingWidth));
8768
- shaftSegments.push([
8769
- origin,
8770
- ptA,
8771
- ptB,
8772
- pBase
8773
- ].map((p) => unproject(p[0], p[1])));
8774
- headPolygons.push([[
8775
- tip,
8776
- pWing1,
8777
- pWing2,
8778
- tip
8779
- ].map((p) => unproject(p[0], p[1]))]);
8780
- };
8781
- addLeg(p1, p2, p3);
8782
- addLeg(p1, p3, p2);
8783
- const features = [];
8784
- if (shaftSegments.length > 0) features.push({
8785
- type: "Feature",
8786
- properties: { part: "shaft" },
8787
- geometry: {
8788
- type: "MultiLineString",
8789
- coordinates: shaftSegments
8790
- }
8791
- });
8792
- if (headPolygons.length > 0) features.push({
8793
- type: "Feature",
8794
- properties: {
8795
- part: "head",
8796
- fill: true
8797
- },
8798
- geometry: {
8799
- type: "MultiPolygon",
8800
- coordinates: headPolygons
8801
- }
8802
- });
8803
- return {
8804
- type: "FeatureCollection",
8805
- features
8806
- };
8807
- }
8808
- const SEARCH_AREA = defineControlMeasure({
8809
- metadata: SEARCH_AREA_METADATA,
8810
- generator: (controlPoints, options) => createSearchArea(asThreePoints(controlPoints), options),
8811
- defaultOptions: DEFAULT_TACTICAL_ARROW_OPTIONS,
8812
- rule: searchAreaDrawRule
8813
- });
8814
- //#endregion
8815
10054
  //#region src/generators/cm15-maneuver-areas/strongPoint.ts
8816
10055
  const DEFAULT_SMOOTH_RESOLUTION = 16;
8817
10056
  /** Default options for the Strong Point control measure. */
@@ -9371,6 +10610,37 @@ function rotate(vector, angle) {
9371
10610
  const sin = Math.sin(angle);
9372
10611
  return [vector[0] * cos - vector[1] * sin, vector[0] * sin + vector[1] * cos];
9373
10612
  }
10613
+ const TURN = defineControlMeasure({
10614
+ metadata: TURN_METADATA,
10615
+ generator: createTurn,
10616
+ defaultOptions: DEFAULT_TURN_OPTIONS,
10617
+ rule: turnDrawRule
10618
+ });
10619
+ //#endregion
10620
+ //#region src/generators/cm15-maneuver-areas/turning-movement.ts
10621
+ const DEFAULT_TURNING_MOVEMENT_OPTIONS = DEFAULT_MANEUVER_ARROW_TASK_OPTIONS;
10622
+ const TURNING_MOVEMENT_METADATA = {
10623
+ id: "turning-movement",
10624
+ name: "Turning Movement",
10625
+ description: "A maneuver that passes around or over an enemy's principal defensive positions to secure objectives to the enemy's rear.",
10626
+ entity: "Maneuver Areas",
10627
+ entityType: "Turning Movement",
10628
+ value: "152900",
10629
+ minCoordinates: 3,
10630
+ geometry: "line",
10631
+ geometryTypes: ["MultiLineString", "Point"],
10632
+ drawRule: "Axis1",
10633
+ capturesLabelSize: true,
10634
+ params: [...ATTACK_SHAFT_PARAMS, ...MANEUVER_ARROW_TASK_PARAMS],
10635
+ textAmplifiers: MANEUVER_ARROW_TASK_TEXT_AMPLIFIERS
10636
+ };
10637
+ function createTurningMovement(coordinates, options = {}, textAmplifiers = {}) {
10638
+ return createManeuverArrowTask(coordinates, options, textAmplifiers, {
10639
+ part: "turning-movement",
10640
+ code: "T",
10641
+ crossbarAt: "tail"
10642
+ });
10643
+ }
9374
10644
  //#endregion
9375
10645
  //#region src/registry.ts
9376
10646
  /**
@@ -9389,12 +10659,16 @@ const DEFINITIONS = {
9389
10659
  "battle-position": BATTLE_POSITION,
9390
10660
  "strong-point": STRONG_POINT,
9391
10661
  ambush: AMBUSH,
10662
+ "direction-of-attack-aviation": DIRECTION_OF_ATTACK_AVIATION,
10663
+ "direction-of-main-attack": DIRECTION_OF_MAIN_ATTACK,
10664
+ "direction-of-supporting-attack": DIRECTION_OF_SUPPORTING_ATTACK,
9392
10665
  "principal-direction-of-fire": PRINCIPAL_DIRECTION_OF_FIRE,
9393
10666
  "final-protective-fire-left": FINAL_PROTECTIVE_FIRE_LEFT,
9394
10667
  "final-protective-fire-right": FINAL_PROTECTIVE_FIRE_RIGHT,
9395
10668
  "search-area": SEARCH_AREA,
9396
10669
  encirclement: ENCIRCLEMENT,
9397
10670
  area: AREA,
10671
+ "area-defense": AREA_DEFENSE,
9398
10672
  "assembly-area": ASSEMBLY_AREA,
9399
10673
  "joint-tactical-action-area": JOINT_TACTICAL_ACTION_AREA,
9400
10674
  "submarine-action-area": SUBMARINE_ACTION_AREA,
@@ -9434,22 +10708,41 @@ const DEFINITIONS = {
9434
10708
  bypass: BYPASS,
9435
10709
  canalize: CANALIZE,
9436
10710
  clear: CLEAR,
10711
+ cover: COVER,
9437
10712
  delay: DELAY,
10713
+ guard: GUARD,
9438
10714
  isolate: ISOLATE,
10715
+ screen: SCREEN,
9439
10716
  "antitank-ditch-under-construction": ANTITANK_DITCH_UNDER_CONSTRUCTION,
9440
10717
  "antitank-ditch-completed": ANTITANK_DITCH_COMPLETED,
9441
10718
  "antitank-wall": ANTITANK_WALL,
9442
10719
  "fortified-line": FORTIFIED_LINE,
9443
10720
  "fortified-area": FORTIFIED_AREA,
10721
+ "frontal-attack": FRONTAL_ATTACK,
10722
+ "turning-movement": defineControlMeasure({
10723
+ metadata: TURNING_MOVEMENT_METADATA,
10724
+ generator: createTurningMovement,
10725
+ defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
10726
+ rule: axis1DrawRule,
10727
+ previewSample: {
10728
+ controlPoints: [
10729
+ [1, 0],
10730
+ [-.4, -.35],
10731
+ [-1, -.8],
10732
+ [.45, .55]
10733
+ ],
10734
+ textAmplifiers: {
10735
+ T: "DAVID",
10736
+ W: "080400Z",
10737
+ W1: "120300Z"
10738
+ },
10739
+ options: { labelSize: 80 }
10740
+ }
10741
+ }),
9444
10742
  block: BLOCK,
9445
10743
  disrupt: DISRUPT,
9446
10744
  fix: FIX,
9447
- turn: defineControlMeasure({
9448
- metadata: TURN_METADATA,
9449
- generator: createTurn,
9450
- defaultOptions: DEFAULT_TURN_OPTIONS,
9451
- rule: turnDrawRule
9452
- }),
10745
+ turn: TURN,
9453
10746
  "obstacle-bypass-easy": OBSTACLE_BYPASS_EASY,
9454
10747
  "obstacle-bypass-difficult": OBSTACLE_BYPASS_DIFFICULT,
9455
10748
  "obstacle-bypass-impossible": OBSTACLE_BYPASS_IMPOSSIBLE
@@ -9691,7 +10984,16 @@ function normalizeTextAmplifiers(input) {
9691
10984
  * `properties.style` when non-empty.
9692
10985
  */
9693
10986
  function renderControlMeasure(cm, opts) {
9694
- const features = dispatchControlMeasure(cm, opts).features.map((feature, index) => normalizeFeature(cm.id, feature, index, cm.style, opts?.graphicsStyle));
10987
+ const raw = dispatchControlMeasure(cm, opts);
10988
+ const generatedLabelOrdinals = /* @__PURE__ */ new Map();
10989
+ const features = raw.features.map((feature, index) => {
10990
+ const sourceProps = feature.properties ?? {};
10991
+ const part = typeof sourceProps.part === "string" && sourceProps.part.length > 0 ? sourceProps.part : "main";
10992
+ const isGeneratedLabel = sourceProps.labelPlacement !== false && !(typeof sourceProps.amplifierField === "string" && canonicalTextAmplifierKey(sourceProps.amplifierField)) && !(typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) && typeof sourceProps.text === "string" && feature.geometry.type === "Point";
10993
+ const generatedLabelOrdinal = isGeneratedLabel ? generatedLabelOrdinals.get(part) ?? 0 : index;
10994
+ if (isGeneratedLabel) generatedLabelOrdinals.set(part, generatedLabelOrdinal + 1);
10995
+ return normalizeFeature(cm.id, feature, index, generatedLabelOrdinal, cm.style, opts?.graphicsStyle, cm.amplifierPlacements);
10996
+ });
9695
10997
  const bbox = computeBBox(features);
9696
10998
  return {
9697
10999
  type: "FeatureCollection",
@@ -9723,7 +11025,20 @@ function validateInputContract(points, metadata, mode) {
9723
11025
  if (points.some((point) => !isValidPosition(point))) return reportValidationIssue(mode, `"${metadata.id}" control points must contain finite [lon, lat] values.`);
9724
11026
  return true;
9725
11027
  }
9726
- function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
11028
+ /**
11029
+ * The `amplifierPlacements` key that repositions this feature, if any: the
11030
+ * canonical amplifier field when the generator declared one, an explicit
11031
+ * `labelPlacementKey`, or a synthesized `label:<part>:<ordinal>` key for any
11032
+ * other point label — unless the generator opted out via
11033
+ * `labelPlacement: false`.
11034
+ */
11035
+ function resolveLabelPlacementKey(sourceProps, amplifierField, text, geometryType, part, generatedLabelOrdinal) {
11036
+ if (sourceProps.labelPlacement === false) return void 0;
11037
+ if (amplifierField) return amplifierField;
11038
+ if (typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) return sourceProps.labelPlacementKey;
11039
+ if (text !== void 0 && geometryType === "Point") return `label:${part}:${generatedLabelOrdinal}`;
11040
+ }
11041
+ function normalizeFeature(id, feature, index, generatedLabelOrdinal, measureStyle, graphicsStyle, amplifierPlacements) {
9727
11042
  const sourceProps = feature.properties ?? {};
9728
11043
  const part = typeof sourceProps.part === "string" && sourceProps.part.length > 0 ? sourceProps.part : "main";
9729
11044
  const generatorStyle = typeof sourceProps.style === "object" && sourceProps.style !== null ? sourceProps.style : void 0;
@@ -9734,6 +11049,13 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
9734
11049
  const textSizePixels = typeof sourceProps.textSizePixels === "number" ? sourceProps.textSizePixels : void 0;
9735
11050
  const textSizeMeters = typeof sourceProps.textSizeMeters === "number" ? sourceProps.textSizeMeters : void 0;
9736
11051
  const textAnchor = sourceProps.textAnchor === "start" || sourceProps.textAnchor === "end" ? sourceProps.textAnchor : void 0;
11052
+ const amplifierField = typeof sourceProps.amplifierField === "string" ? canonicalTextAmplifierKey(sourceProps.amplifierField) : void 0;
11053
+ const labelPlacementKey = resolveLabelPlacementKey(sourceProps, amplifierField, text, feature.geometry.type, part, generatedLabelOrdinal);
11054
+ const placement = labelPlacementKey ? amplifierPlacements?.[labelPlacementKey] : void 0;
11055
+ const geometry = placement && isValidPosition(placement) && feature.geometry.type === "Point" ? {
11056
+ ...feature.geometry,
11057
+ coordinates: [...placement]
11058
+ } : feature.geometry;
9737
11059
  return {
9738
11060
  type: "Feature",
9739
11061
  id: `${id}:${part}:${index}`,
@@ -9744,10 +11066,12 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
9744
11066
  ...text !== void 0 ? { text } : {},
9745
11067
  ...rotation !== void 0 ? { rotation } : {},
9746
11068
  ...textAnchor !== void 0 ? { textAnchor } : {},
11069
+ ...amplifierField !== void 0 ? { amplifierField } : {},
11070
+ ...labelPlacementKey !== void 0 ? { labelPlacementKey } : {},
9747
11071
  ...textSizePixels !== void 0 ? { textSizePixels } : {},
9748
11072
  ...textSizeMeters !== void 0 ? { textSizeMeters } : {}
9749
11073
  },
9750
- geometry: feature.geometry
11074
+ geometry
9751
11075
  };
9752
11076
  }
9753
11077
  function computeBBox(features) {
@@ -9796,4 +11120,4 @@ function assertNever(value) {
9796
11120
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
9797
11121
  }
9798
11122
  //#endregion
9799
- export { DEFAULT_BATTLE_POSITION_OPTIONS as $, DEFAULT_PHASE_LINE_OPTIONS as A, unproject as At, DEFAULT_GENERIC_LINE_OPTIONS as B, DEFAULT_MAIN_ATTACK_OPTIONS as C, getMidpointPerpendicularSignedDistance as Ct, DEFAULT_FORTIFIED_LINE_OPTIONS as D, calculateMetrics as Dt, DEFAULT_FORTIFIED_AREA_OPTIONS as E, createBaselineFrame as Et, DEFAULT_DISRUPT_OPTIONS as F, DEFAULT_BREACH_OPTIONS as G, DEFAULT_CLASSIC_ARROW_OPTIONS as H, DEFAULT_DELAY_OPTIONS as I, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as J, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as K, DEFAULT_CLEAR_OPTIONS as L, DEFAULT_FIX_OPTIONS as M, getMetersPerPixel as Mt, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as N, roundToFixed as Nt, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as O, computeInitialWidthPoint as Ot, DEFAULT_ENCIRCLEMENT_OPTIONS as P, DEFAULT_BLOCK_OPTIONS as Q, DEFAULT_GENERIC_RECTANGLE_OPTIONS as R, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as S, createMidpointPerpendicularDrawRule as St, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as T, snapToMidpointPerpendicular as Tt, DEFAULT_CANALIZE_OPTIONS as U, DEFAULT_GENERIC_CIRCLE_OPTIONS as V, DEFAULT_BYPASS_OPTIONS as W, DEFAULT_BOUNDARY_OPTIONS as X, DEFAULT_LIGHT_LINE_OPTIONS as Y, DEFAULT_BLOCK_ARROW_OPTIONS as Z, DEFAULT_TACTICAL_ARROW_OPTIONS as _, point12DrawRule as _t, resolveStyleHints as a, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as b, blockDrawRule as bt, DEFINITIONS as c, rectangleDrawRule as ct, getDefaultOptions as d, line24DrawRule as dt, DEFAULT_ATTACK_HELICOPTER_OPTIONS as et, listControlMeasureMetadata as f, line23DrawRule as ft, DEFAULT_STRONG_POINT_OPTIONS as g, attackByFireDrawRule as gt, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as h, ambushDrawRule as ht, normalizeTextAmplifiers as i, DEFAULT_ASSEMBLY_AREA_OPTIONS as it, DEFAULT_FLOT_OPTIONS as j, EPSILON as jt, DEFAULT_HANDOVER_LINE_OPTIONS as k, project as kt, getControlMeasureMetadata as l, axis1DrawRule as lt, DEFAULT_SUPPORTING_ATTACK_OPTIONS as m, line1DrawRule as mt, TEXT_AMPLIFIER_FIELDS as n, DEFAULT_ANTITANK_WALL_OPTIONS as nt, CONTROL_MEASURE_IDS as o, DEFAULT_AMBUSH_OPTIONS as ot, DEFAULT_TURN_OPTIONS as p, turnDrawRule as pt, DEFAULT_GENERIC_C2_LINE_OPTIONS as q, canonicalTextAmplifierKey as r, DEFAULT_ANTITANK_DITCH_OPTIONS as rt, CONTROL_MEASURE_METADATA as s, DEFAULT_AIRBORNE_ATTACK_OPTIONS as st, renderControlMeasure as t, DEFAULT_ATTACK_BY_FIRE_OPTIONS as tt, getControlMeasureMetadataByValue as u, supportByFireDrawRule as ut, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as v, centerRadiusDrawRule as vt, DEFAULT_LANDING_ZONE_OPTIONS as w, pointOnMidpointPerpendicularAxis as wt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as x, computeDefaultMidpointPerpendicularPoint as xt, DEFAULT_PICKUP_ZONE_OPTIONS as y, disruptDrawRule as yt, DEFAULT_GENERIC_POLYGON_OPTIONS as z };
11123
+ export { DEFAULT_ENGINEER_WORK_LINE_OPTIONS as $, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as A, blockDrawRule as At, DEFAULT_COVER_OPTIONS as B, unproject as Bt, DEFAULT_MAIN_ATTACK_OPTIONS as C, turnDrawRule as Ct, DEFAULT_FRONTAL_ATTACK_OPTIONS as D, point12DrawRule as Dt, DEFAULT_SCREEN_OPTIONS as E, attackByFireDrawRule as Et, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as F, snapToMidpointPerpendicular as Ft, DEFAULT_GENERIC_LINE_OPTIONS as G, DEFAULT_CLEAR_OPTIONS as H, getMetersPerPixel as Ht, DEFAULT_ENCIRCLEMENT_OPTIONS as I, createBaselineFrame as It, DEFAULT_CANALIZE_OPTIONS as J, DEFAULT_GENERIC_CIRCLE_OPTIONS as K, DEFAULT_DISRUPT_OPTIONS as L, calculateMetrics as Lt, DEFAULT_PHASE_LINE_OPTIONS as M, createMidpointPerpendicularDrawRule as Mt, DEFAULT_FLOT_OPTIONS as N, getMidpointPerpendicularSignedDistance as Nt, DEFAULT_FORTIFIED_AREA_OPTIONS as O, centerRadiusDrawRule as Ot, DEFAULT_FIX_OPTIONS as P, pointOnMidpointPerpendicularAxis as Pt, DEFAULT_GENERIC_C2_LINE_OPTIONS as Q, DEFAULT_GUARD_OPTIONS as R, computeInitialWidthPoint as Rt, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as S, line23DrawRule as St, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as T, ambushDrawRule as Tt, DEFAULT_GENERIC_RECTANGLE_OPTIONS as U, roundToFixed as Ut, DEFAULT_TACTICAL_ARROW_OPTIONS as V, EPSILON as Vt, DEFAULT_GENERIC_POLYGON_OPTIONS as W, DEFAULT_BREACH_OPTIONS as X, DEFAULT_BYPASS_OPTIONS as Y, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Z, DEFAULT_STRONG_POINT_OPTIONS as _, rectangleDrawRule as _t, resolveStyleHints as a, DEFAULT_ATTACK_HELICOPTER_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as b, line26DrawRule as bt, DEFINITIONS as c, DEFAULT_ANTITANK_DITCH_OPTIONS as ct, getDefaultOptions as d, DEFAULT_AREA_DEFENSE_OPTIONS as dt, DEFAULT_LIGHT_LINE_OPTIONS as et, listControlMeasureMetadata as f, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as ft, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as g, DEFAULT_AIRBORNE_ATTACK_OPTIONS as gt, DEFAULT_SUPPORTING_ATTACK_OPTIONS as h, DEFAULT_AMBUSH_OPTIONS as ht, normalizeTextAmplifiers as i, DEFAULT_BATTLE_POSITION_OPTIONS as it, DEFAULT_HANDOVER_LINE_OPTIONS as j, computeDefaultMidpointPerpendicularPoint as jt, DEFAULT_FORTIFIED_LINE_OPTIONS as k, disruptDrawRule as kt, getControlMeasureMetadata as l, DEFAULT_ASSEMBLY_AREA_OPTIONS as lt, DEFAULT_TURN_OPTIONS as m, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as mt, TEXT_AMPLIFIER_FIELDS as n, DEFAULT_BLOCK_ARROW_OPTIONS as nt, CONTROL_MEASURE_IDS as o, DEFAULT_ATTACK_BY_FIRE_OPTIONS as ot, DEFAULT_TURNING_MOVEMENT_OPTIONS as p, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as pt, DEFAULT_CLASSIC_ARROW_OPTIONS as q, canonicalTextAmplifierKey as r, DEFAULT_BLOCK_OPTIONS as rt, CONTROL_MEASURE_METADATA as s, DEFAULT_ANTITANK_WALL_OPTIONS as st, renderControlMeasure as t, DEFAULT_BOUNDARY_OPTIONS as tt, getControlMeasureMetadataByValue as u, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as ut, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as v, axis1DrawRule as vt, DEFAULT_LANDING_ZONE_OPTIONS as w, line1DrawRule as wt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as x, line24DrawRule as xt, DEFAULT_PICKUP_ZONE_OPTIONS as y, supportByFireDrawRule as yt, DEFAULT_DELAY_OPTIONS as z, project as zt };