@orbat-mapper/control-measures 0.2.0-alpha.12 → 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.
@@ -1916,177 +1983,6 @@ function closedCatmullRom(points, samples) {
1916
1983
  return out;
1917
1984
  }
1918
1985
  //#endregion
1919
- //#region src/internal/area-echelon.ts
1920
- /**
1921
- * Twice the ring's signed area (shoelace). Positive when the ring winds
1922
- * counter-clockwise in projected (y-up) coordinates. Exported for reuse by
1923
- * `area-labels.ts`'s polygon-centroid label placement, so the area centroid
1924
- * shares this one derivation instead of re-deriving it.
1925
- */
1926
- function ringSignedArea2(verts) {
1927
- let area2 = 0;
1928
- for (let i = 0; i < verts.length - 1; i++) {
1929
- const a = verts[i];
1930
- const b = verts[i + 1];
1931
- area2 += a[0] * b[1] - b[0] * a[1];
1932
- }
1933
- return area2;
1934
- }
1935
- /**
1936
- * Projects `positions` and returns a closed ring (first == last), optionally
1937
- * smoothed through the control points with a closed centripetal Catmull-Rom
1938
- * spline. A trailing vertex that duplicates the first (already-closed input)
1939
- * is dropped before closing/smoothing.
1940
- */
1941
- function buildClosedRing(positions, smooth, smoothResolution, defaultSmoothResolution) {
1942
- const projected = positions.map((p) => project(p[0], p[1]));
1943
- 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;
1944
- return smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, defaultSmoothResolution)) : [...ringPts, ringPts[0]];
1945
- }
1946
- /**
1947
- * Walks a closed ring into per-segment metadata (unit direction, outward
1948
- * normal, cumulative arc length) plus the total perimeter and the arc-length
1949
- * anchor of the bottom edge — the segment whose midpoint sits lowest (min
1950
- * projected y), the side opposite the front.
1951
- */
1952
- function buildAreaPerimeter(verts) {
1953
- const ccw = ringSignedArea2(verts) > 0;
1954
- const segs = [];
1955
- let totalLength = 0;
1956
- let anchorTarget = 0;
1957
- let lowest = Infinity;
1958
- for (let i = 0; i < verts.length - 1; i++) {
1959
- const a = verts[i];
1960
- const b = verts[i + 1];
1961
- const edge = vecSub(b, a);
1962
- const length = vecMag(edge);
1963
- if (length < 1e-6) continue;
1964
- const along = vecNorm(edge);
1965
- const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1966
- const perp = ccw ? [along[1], -along[0]] : [-along[1], along[0]];
1967
- segs.push({
1968
- start: a,
1969
- along,
1970
- perp,
1971
- length,
1972
- arcStart: totalLength
1973
- });
1974
- if (mid[1] < lowest) {
1975
- lowest = mid[1];
1976
- anchorTarget = totalLength + length / 2;
1977
- }
1978
- totalLength += length;
1979
- }
1980
- return {
1981
- verts,
1982
- segs,
1983
- totalLength,
1984
- anchorTarget
1985
- };
1986
- }
1987
- /** Resolves the arc-length `target` to a point and the segment frame it lands on. */
1988
- function pointOnRing(segs, target) {
1989
- let seg = segs[segs.length - 1];
1990
- for (const candidate of segs) if (target < candidate.arcStart + candidate.length) {
1991
- seg = candidate;
1992
- break;
1993
- }
1994
- return {
1995
- point: vecAdd(seg.start, vecScale(seg.along, target - seg.arcStart)),
1996
- seg
1997
- };
1998
- }
1999
- /**
2000
- * Effective echelon glyph height in meters. Screen-anchored when a pixel size
2001
- * is supplied with a live resolution; ground-anchored (meters) otherwise.
2002
- * Clamped strictly positive.
2003
- */
2004
- function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
2005
- let h = echelonSize;
2006
- if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
2007
- return Math.max(EPSILON, h);
2008
- }
2009
- /** Wraps `x` into `[0, 1)`, treating `x` as a fraction (matches the arc-length wrap below). */
2010
- function wrap01(x) {
2011
- return (x % 1 + 1) % 1;
2012
- }
2013
- /**
2014
- * Places the Field B echelon glyph on the perimeter and computes its masking
2015
- * gap, anchored by default to the bottom edge and slid by `echelonPosition`
2016
- * (a signed fraction of the perimeter, wrapping at the seam). When
2017
- * `echelonAnchor` is supplied, it replaces the bottom-edge anchor with an
2018
- * absolute perimeter fraction (measured from the ring seam) — see
2019
- * `BattlePositionOptions.echelonAnchor` for why (ADR-0023 rotate-freeze).
2020
- * Returns empty geometry when the echelon resolves to none or the ring is
2021
- * degenerate.
2022
- */
2023
- function placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, echelonAnchor) {
2024
- const { segs, totalLength, anchorTarget } = perimeter;
2025
- const spec = resolveEchelon(echelon);
2026
- if (!spec || segs.length === 0 || totalLength < 1e-6) return {
2027
- strokes: [],
2028
- fills: [],
2029
- gaps: []
2030
- };
2031
- const target = (((echelonAnchor !== void 0 ? wrap01(echelonAnchor) * totalLength : anchorTarget) + echelonPosition * totalLength) % totalLength + totalLength) % totalLength;
2032
- const { point, seg } = pointOnRing(segs, target);
2033
- const { strokes, fills, width } = buildEchelonGlyph(point, seg.along, seg.perp, h, spec);
2034
- const gaps = [];
2035
- const halfGap = width / 2 + Math.max(0, echelonPadding) * h;
2036
- pushWrappedGap(gaps, target - halfGap, target + halfGap, totalLength);
2037
- return {
2038
- strokes,
2039
- fills,
2040
- gaps
2041
- };
2042
- }
2043
- /**
2044
- * Factory for the `freezeOrientation` hook (ADR-0023) shared by every
2045
- * echelon-carrying closed-area measure (Battle Position, Strong Point, Base
2046
- * Camp, Guerrilla Base): pins the echelon anchor to its current bottom-edge
2047
- * fraction so a subsequent rigid rotation of `controlPoints` (e.g. the
2048
- * transform box's rotate grip) does not re-derive a different "bottom edge"
2049
- * against the rotated geometry. `defaults` supplies the measure's own
2050
- * `echelon`/`smooth`/`smoothResolution` defaults, since each measure's
2051
- * `DEFAULT_*_OPTIONS` differ (e.g. Battle Position's `echelon: "battalion"`
2052
- * vs. Base Camp/Guerrilla Base's `echelon: "none"`).
2053
- *
2054
- * The returned hook returns `undefined` — nothing to freeze — when:
2055
- * - `echelonAnchor` is already set (previously frozen or user-authored), or
2056
- * - the echelon resolves to none (nothing visible to pin), or
2057
- * - the ring is degenerate (no segments, or a near-zero perimeter).
2058
- */
2059
- function makeFreezeEchelonAnchor(defaults) {
2060
- return function freezeEchelonAnchor(controlPoints, options) {
2061
- if (options.echelonAnchor !== void 0) return void 0;
2062
- if (!resolveEchelon(options.echelon ?? defaults.echelon)) return void 0;
2063
- const perimeter = buildAreaPerimeter(buildClosedRing(controlPoints, options.smooth ?? defaults.smooth, options.smoothResolution ?? defaults.smoothResolution, defaults.smoothResolution));
2064
- if (perimeter.segs.length === 0 || perimeter.totalLength < 1e-6) return void 0;
2065
- return { echelonAnchor: perimeter.anchorTarget / perimeter.totalLength };
2066
- };
2067
- }
2068
- //#endregion
2069
- //#region src/internal/angle-utils.ts
2070
- /**
2071
- * Wraps an angle (radians) into the half-open range (-π, π].
2072
- */
2073
- function normalizeRadians(angle) {
2074
- let normalized = angle % (Math.PI * 2);
2075
- if (normalized > Math.PI) normalized -= Math.PI * 2;
2076
- if (normalized <= -Math.PI) normalized += Math.PI * 2;
2077
- return normalized;
2078
- }
2079
- /**
2080
- * Flips a text rotation by π when it would otherwise read upside-down, keeping
2081
- * the baseline pointing left-to-right (within ±90° of horizontal).
2082
- */
2083
- function keepTextLeftToRight(rotation) {
2084
- const normalized = normalizeRadians(rotation);
2085
- if (normalized > Math.PI / 2) return normalized - Math.PI;
2086
- if (normalized < -Math.PI / 2) return normalized + Math.PI;
2087
- return normalized;
2088
- }
2089
- //#endregion
2090
1986
  //#region src/internal/line-labels.ts
2091
1987
  /**
2092
1988
  * Stored label rotation for text running along projected direction `dir`.
@@ -2390,29 +2286,675 @@ function fixedLabelLineFeatures(positions, options, textForEnd) {
2390
2286
  };
2391
2287
  }
2392
2288
  //#endregion
2393
- //#region src/internal/area-labels.ts
2394
- /** Stored label rotation for horizontal (screen-upright) area text — adapters
2395
- * render feature rotation as `π − rotation`, so `π` renders as `0`. Exported
2396
- * for reuse by measures that place a horizontal label outside the area's
2397
- * boundary (e.g. Airfield Zone's Field H). */
2398
- const HORIZONTAL_LABEL_ROTATION = Math.PI;
2399
- /** Row pitch, as a ratio of the resolved label height, between stacked
2400
- * name/H/W-W1 rows (see {@link pushAreaLabels}). */
2401
- const ROW_PITCH_RATIO = 1.2;
2402
- /** Clearance, as a ratio of the resolved label height, added on top of each
2403
- * ENY marker's own half-width/half-height when carving its boundary
2404
- * knockout gap (see {@link pushAreaLabels}). */
2405
- const ENY_CLEARANCE_RATIO = .3;
2406
- /** Approximate sans-serif character aspect ratio (width/height) used to
2407
- * estimate an ENY marker's rendered text width from its character count,
2408
- * for sizing the boundary knockout gap — no live text-measurement is
2409
- * available at generator time. */
2410
- const ENY_CHAR_ASPECT_RATIO = .6;
2411
- const DEFAULT_SMOOTH_RESOLUTION$9 = 16;
2412
- /**
2413
- * Composes an area's main label text from a `prefix` (e.g. `"AO"`) and a
2414
- * `designation` (e.g. `"GREEN"`), mirroring Phase Line's "PL" precedent: a
2415
- * non-empty trimmed `designation` joins onto `prefix` with `separator`; an
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
2945
+ * ENY marker's own half-width/half-height when carving its boundary
2946
+ * knockout gap (see {@link pushAreaLabels}). */
2947
+ const ENY_CLEARANCE_RATIO = .3;
2948
+ /** Approximate sans-serif character aspect ratio (width/height) used to
2949
+ * estimate an ENY marker's rendered text width from its character count,
2950
+ * for sizing the boundary knockout gap — no live text-measurement is
2951
+ * available at generator time. */
2952
+ const ENY_CHAR_ASPECT_RATIO = .6;
2953
+ const DEFAULT_SMOOTH_RESOLUTION$9 = 16;
2954
+ /**
2955
+ * Composes an area's main label text from a `prefix` (e.g. `"AO"`) and a
2956
+ * `designation` (e.g. `"GREEN"`), mirroring Phase Line's "PL" precedent: a
2957
+ * non-empty trimmed `designation` joins onto `prefix` with `separator`; an
2416
2958
  * empty/absent `designation` yields the bare `prefix` (no bare fallback of
2417
2959
  * its own); both empty yields `""`.
2418
2960
  */
@@ -2679,6 +3221,203 @@ const AREA = defineControlMeasure({
2679
3221
  }
2680
3222
  });
2681
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,
3415
+ previewSample: {
3416
+ controlPoints: [[0, 0], [-.77, .21]],
3417
+ options: { labelSize: 90 }
3418
+ }
3419
+ });
3420
+ //#endregion
2682
3421
  //#region src/generators/cm12-command-control-areas/area-of-operations.ts
2683
3422
  /** Default options for the Area of operations control measure. */
2684
3423
  const DEFAULT_AREA_OF_OPERATIONS_OPTIONS = {
@@ -5068,7 +5807,7 @@ const DEFAULT_BLOCK_MISSION_TASK_OPTIONS = { labelGapRatio: .2 };
5068
5807
  const BLOCK_MISSION_TASK_METADATA = {
5069
5808
  id: "block-mission-task",
5070
5809
  name: "Block",
5071
- 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.",
5072
5811
  entity: "Mission Tasks",
5073
5812
  entityType: "Block",
5074
5813
  value: "340100",
@@ -5160,7 +5899,7 @@ const DEFAULT_BREACH_OPTIONS = {
5160
5899
  const BREACH_METADATA = {
5161
5900
  id: "breach",
5162
5901
  name: "Breach",
5163
- 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.",
5164
5903
  entity: "Mission Tasks",
5165
5904
  entityType: "Breach",
5166
5905
  value: "340200",
@@ -5256,7 +5995,7 @@ const DEFAULT_BYPASS_OPTIONS = {
5256
5995
  const BYPASS_METADATA = {
5257
5996
  id: "bypass",
5258
5997
  name: "Bypass",
5259
- 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.",
5260
5999
  entity: "Mission Tasks",
5261
6000
  entityType: "Bypass",
5262
6001
  value: "340300",
@@ -5331,7 +6070,7 @@ const DEFAULT_CANALIZE_OPTIONS = {
5331
6070
  const CANALIZE_METADATA = {
5332
6071
  id: "canalize",
5333
6072
  name: "Canalize",
5334
- 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.",
5335
6074
  entity: "Mission Tasks",
5336
6075
  entityType: "Canalize",
5337
6076
  value: "340400",
@@ -5966,170 +6705,562 @@ function createGenericRectangle(coordinates, options = {}) {
5966
6705
  }]
5967
6706
  };
5968
6707
  }
5969
- const GENERIC_RECTANGLE = defineControlMeasure({
5970
- metadata: GENERIC_RECTANGLE_METADATA,
5971
- generator: createGenericRectangle,
5972
- defaultOptions: DEFAULT_GENERIC_RECTANGLE_OPTIONS,
5973
- rule: rectangleDrawRule,
5974
- previewSample: { controlPoints: [
5975
- [-.9, -.55],
5976
- [.9, -.55],
5977
- [.9, .55]
5978
- ] }
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
5979
6872
  });
5980
6873
  //#endregion
5981
- //#region src/generators/cm34-mission-tasks/clear.ts
6874
+ //#region src/internal/field-of-fire.ts
5982
6875
  /**
5983
- * Default options for the CLEAR symbol.
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.
5984
6880
  */
5985
- const DEFAULT_CLEAR_OPTIONS = {
5986
- arrowInsetRatio: .15,
5987
- arrowheadLengthRatio: .15,
5988
- arrowheadWidthRatio: .2,
5989
- labelGapRatio: .2
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
6920
+ };
6921
+ }
6922
+ //#endregion
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;
6929
+ /**
6930
+ * Default options for the tactical arrow.
6931
+ */
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
5990
6938
  };
5991
- const CLEAR_METADATA = {
5992
- id: "clear",
5993
- name: "Clear",
5994
- description: "Clear is a tactical mission task that requires a unit to remove all enemy forces and eliminate organized resistance within an assigned area.",
5995
- entity: "Mission Tasks",
5996
- entityType: "Clear",
5997
- 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",
5998
6946
  minCoordinates: 3,
5999
6947
  maxCoordinates: 3,
6000
- geometry: "line",
6001
- geometryTypes: ["MultiLineString", "Point"],
6002
- drawRule: "Line23",
6003
- capturesLabelSize: true,
6948
+ geometry: "area",
6949
+ geometryTypes: ["MultiLineString", "MultiPolygon"],
6950
+ drawRule: "Area21",
6004
6951
  params: [
6005
- {
6006
- key: "arrowInsetRatio",
6007
- label: "Arrow padding",
6008
- description: "Padding between the line endpoints and the outer arrows, as a ratio of height",
6009
- type: "number",
6010
- min: 0,
6011
- max: .45,
6012
- step: .01
6013
- },
6014
6952
  {
6015
6953
  key: "arrowheadLengthRatio",
6016
6954
  label: "Arrowhead length",
6017
- description: "Arrowhead length as a ratio of the symbol height",
6955
+ description: "Arrowhead length as a ratio of the total leg length",
6018
6956
  type: "number",
6019
- min: .01,
6020
- max: 1,
6957
+ min: 0,
6958
+ max: .5,
6021
6959
  step: .01
6022
6960
  },
6023
6961
  {
6024
6962
  key: "arrowheadWidthRatio",
6025
6963
  label: "Arrowhead width",
6026
- description: "Arrowhead base width as a ratio of the symbol height",
6964
+ description: "Arrowhead base width as a multiple of the arrowhead length",
6027
6965
  type: "number",
6028
- 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,
6029
6976
  max: 1,
6030
- step: .01
6977
+ step: .05
6031
6978
  },
6032
6979
  {
6033
- key: "labelGapRatio",
6034
- label: "Label gap",
6035
- 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).",
6036
6983
  type: "number",
6037
6984
  min: 0,
6038
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,
6039
6995
  step: .01
6040
6996
  }
6041
6997
  ]
6042
6998
  };
6043
6999
  /**
6044
- * Generates a GeoJSON FeatureCollection for a CLEAR mission task symbol (340500).
6045
- *
6046
- * A vertical line (PT. 1 → PT. 2) with three arrows pointing at it,
6047
- * perpendicular to the line. The arrows come from the rear — the side PT. 3
6048
- * sits on — and their tips touch the vertical line; each carries a shaft
6049
- * reaching back to the rear. The arrows are evenly spaced along the line, so
6050
- * the middle arrow's tip sits at the midpoint of the vertical line and its
6051
- * shaft carries the 'C' glyph. The arrows stay perpendicular to the vertical
6052
- * line at any rotation.
6053
- *
6054
- * Input Points:
6055
- * - PT. 1: Top endpoint of the vertical line
6056
- * - PT. 2: Bottom endpoint of the vertical line — defines height with PT. 1
6057
- * - PT. 3: Rear point — defines the shaft length (and which side the arrows
6058
- * come from)
6059
- *
6060
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
6061
- * @param options - Configuration options
6062
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
7000
+ * Generates a GeoJSON FeatureCollection of a tactical "lightning" arrow.
7001
+ * Handles internal projection to maintain correct perpendicularity.
7002
+ *
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.
6063
7157
  */
6064
- function createClearSymbol(coordinates, options = {}) {
6065
- const arrowInsetRatio = Math.min(.49, Math.max(0, options.arrowInsetRatio ?? DEFAULT_CLEAR_OPTIONS.arrowInsetRatio));
6066
- const arrowheadLengthRatio = options.arrowheadLengthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadLengthRatio;
6067
- const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadWidthRatio;
6068
- const labelGapRatio = options.labelGapRatio ?? DEFAULT_CLEAR_OPTIONS.labelGapRatio;
6069
- const [c1, c2, c3] = coordinates;
7158
+ function createSecurityTaskSymbol(coordinates, config, options = {}) {
7159
+ const { taskGlyph, part } = config;
7160
+ const [c1, c2, c3, c4] = coordinates;
6070
7161
  const p1 = project(c1[0], c1[1]);
6071
7162
  const p2 = project(c2[0], c2[1]);
6072
7163
  const p3 = project(c3[0], c3[1]);
6073
- const vLine = vecSub(p2, p1);
6074
- const height = vecMag(vLine);
6075
- 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 {
6076
7178
  type: "FeatureCollection",
6077
7179
  features: []
6078
7180
  };
6079
- const dirLine = vecScale(vLine, 1 / height);
6080
- const midLine = vecScale(vecAdd(p1, p2), .5);
6081
- const rawPerp = [-dirLine[1], dirLine[0]];
6082
- const perpToRear = vecScale(rawPerp, vecDot(vecSub(p3, midLine), rawPerp) < 0 ? -1 : 1);
6083
- const shaftLength = Math.abs(vecDot(vecSub(p3, midLine), rawPerp));
6084
- const arrowDir = vecScale(perpToRear, -1);
6085
- const arrowheadLength = height * arrowheadLengthRatio;
6086
- const arrowheadWidth = height * arrowheadWidthRatio;
6087
- const tipAt = (t) => vecAdd(p1, vecScale(dirLine, t * height));
6088
- const lines = [[unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]];
6089
- const fullTs = [
6090
- arrowInsetRatio,
6091
- .5,
6092
- 1 - arrowInsetRatio
6093
- ];
6094
- const labelIndex = 1;
6095
- fullTs.forEach((t, i) => {
6096
- const tip = tipAt(t);
6097
- const rearEnd = vecAdd(tip, vecScale(perpToRear, shaftLength));
6098
- if (i === labelIndex && shaftLength > 1e-6) {
6099
- const labelCenter = vecAdd(tip, vecScale(perpToRear, shaftLength * .5));
6100
- const gapHalf = shaftLength * labelGapRatio / 2;
6101
- const gapRear = vecAdd(labelCenter, vecScale(perpToRear, gapHalf));
6102
- const gapTip = vecAdd(labelCenter, vecScale(perpToRear, -gapHalf));
6103
- lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
6104
- lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
6105
- } else if (shaftLength > 1e-6) lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(tip[0], tip[1])]);
6106
- lines.push(createArrowHead$1(tip, arrowDir, arrowheadLength, arrowheadWidth));
6107
- });
6108
- const mainFeature = {
7181
+ const features = [{
6109
7182
  type: "Feature",
6110
- properties: { part: "clear" },
7183
+ properties: { part },
6111
7184
  geometry: {
6112
7185
  type: "MultiLineString",
6113
7186
  coordinates: lines
6114
7187
  }
6115
- };
6116
- const lineRotation = Math.atan2(dirLine[1], dirLine[0]);
6117
- const renderedLabelRotation = Math.PI - (lineRotation - Math.PI / 2);
6118
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
6119
- const labelTip = tipAt(fullTs[labelIndex]);
6120
- return {
6121
- type: "FeatureCollection",
6122
- 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 = {
6123
7195
  labelSize: options.labelSize,
6124
7196
  labelSizePixels: options.labelSizePixels
6125
- })]
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
6126
7203
  };
6127
7204
  }
6128
- const CLEAR = defineControlMeasure({
6129
- metadata: CLEAR_METADATA,
6130
- generator: createClearSymbol,
6131
- defaultOptions: DEFAULT_CLEAR_OPTIONS,
6132
- 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
+ }
6133
7264
  });
6134
7265
  //#endregion
6135
7266
  //#region src/generators/cm34-mission-tasks/delay.ts
@@ -6142,7 +7273,7 @@ const DEFAULT_DELAY_OPTIONS = {
6142
7273
  const DELAY_METADATA = {
6143
7274
  id: "delay",
6144
7275
  name: "Delay",
6145
- 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.",
6146
7277
  entity: "Mission Tasks",
6147
7278
  entityType: "Delay",
6148
7279
  value: "340800",
@@ -6257,6 +7388,46 @@ const DELAY = defineControlMeasure({
6257
7388
  rule: line24DrawRule
6258
7389
  });
6259
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
6260
7431
  //#region src/internal/arrowhead.ts
6261
7432
  /**
6262
7433
  * Builds a closed, filled arrowhead triangle ring in unprojected coordinates.
@@ -6719,55 +7890,6 @@ const EXTRACTION_ZONE = defineControlMeasure({
6719
7890
  }
6720
7891
  });
6721
7892
  //#endregion
6722
- //#region src/internal/field-of-fire.ts
6723
- /**
6724
- * Builds the open (stroked) arrowhead at the outward tip of one arm. The two
6725
- * barbs are sized relative to that arm's own length, so each arm's head scales
6726
- * independently (Line3 — arms vary independently). Returns `null` when the arm
6727
- * has collapsed to zero length.
6728
- */
6729
- function arrowheadAt(vertex, tip, hSize, hWidth) {
6730
- const v = vecSub(tip, vertex);
6731
- const armLength = vecMag(v);
6732
- if (armLength < 1e-6) return null;
6733
- const dir = vecNorm(v);
6734
- const perp = [-dir[1], dir[0]];
6735
- const headLength = armLength * hSize;
6736
- const halfWidth = headLength * hWidth;
6737
- const base = vecSub(tip, vecScale(dir, headLength));
6738
- return [
6739
- vecAdd(base, vecScale(perp, halfWidth)),
6740
- tip,
6741
- vecSub(base, vecScale(perp, halfWidth))
6742
- ];
6743
- }
6744
- /**
6745
- * Projects the vertex (PT 1) and up to two tips and builds the V of arms, each
6746
- * arm ending in an open arrowhead. Renders one arm per tip, so two points
6747
- * (vertex + one tip) draws the single arm being placed — the live feedback
6748
- * after PT 1 — and three points draws the full V. Points beyond the third are
6749
- * ignored (input contract, ADR-0014).
6750
- *
6751
- * Returns `null` if any arm is degenerate (collapsed to zero length), the
6752
- * silent ADR-0014 empty-render signal.
6753
- */
6754
- function buildFieldOfFireV(coordinates, hSize, hWidth) {
6755
- const vertexCoord = coordinates[0];
6756
- const vertex = project(vertexCoord[0], vertexCoord[1]);
6757
- const tips = coordinates.slice(1, 3).map((c) => project(c[0], c[1]));
6758
- const lines = [];
6759
- for (const tip of tips) {
6760
- const head = arrowheadAt(vertex, tip, hSize, hWidth);
6761
- if (!head) return null;
6762
- lines.push([vertex, tip], head);
6763
- }
6764
- return {
6765
- vertex,
6766
- tips,
6767
- lines
6768
- };
6769
- }
6770
- //#endregion
6771
7893
  //#region src/generators/cm14-maneuver-lines/finalProtectiveFire.ts
6772
7894
  const BLADE_LENGTH_FRACTION = .8;
6773
7895
  const DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS = {
@@ -7702,20 +8824,214 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
7702
8824
  features
7703
8825
  };
7704
8826
  }
7705
- const FORTIFIED_AREA = defineControlMeasure({
7706
- metadata: FORTIFIED_AREA_METADATA,
7707
- generator: createFortifiedArea,
7708
- defaultOptions: DEFAULT_FORTIFIED_AREA_OPTIONS,
7709
- rule: area1DrawRule,
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
+ });
9016
+ }
9017
+ const FRONTAL_ATTACK = defineControlMeasure({
9018
+ metadata: FRONTAL_ATTACK_METADATA,
9019
+ generator: createFrontalAttack,
9020
+ defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
9021
+ rule: axis1DrawRule,
7710
9022
  previewSample: {
7711
9023
  controlPoints: [
7712
- [-1, -.5],
7713
- [1, -.5],
7714
- [1, .5],
7715
- [-1, .5]
9024
+ [1, 0],
9025
+ [-.4, -.35],
9026
+ [-1, -.8],
9027
+ [.45, .55]
7716
9028
  ],
7717
- textAmplifiers: { T: "TANGO" },
7718
- options: { labelSize: 96 }
9029
+ textAmplifiers: {
9030
+ T: "FOX",
9031
+ W: "080400Z",
9032
+ W1: "120300Z"
9033
+ },
9034
+ options: { labelSize: 80 }
7719
9035
  }
7720
9036
  });
7721
9037
  //#endregion
@@ -7731,7 +9047,7 @@ const DEFAULT_ISOLATE_OPTIONS = {
7731
9047
  const ISOLATE_METADATA = {
7732
9048
  id: "isolate",
7733
9049
  name: "Isolate",
7734
- 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.",
7735
9051
  entity: "Mission Tasks",
7736
9052
  entityType: "Isolate",
7737
9053
  value: "341500",
@@ -7849,6 +9165,46 @@ const ISOLATE = defineControlMeasure({
7849
9165
  rule: isolateDrawRule
7850
9166
  });
7851
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
7852
9208
  //#region src/generators/cm15-maneuver-areas/jointTacticalActionArea.ts
7853
9209
  /** Default options for the Joint tactical action area control measure. */
7854
9210
  const DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS = {
@@ -8695,173 +10051,6 @@ const PRINCIPAL_DIRECTION_OF_FIRE = defineControlMeasure({
8695
10051
  ] }
8696
10052
  });
8697
10053
  //#endregion
8698
- //#region src/generators/cm15-maneuver-areas/searchArea.ts
8699
- const DEFAULT_ARROWHEAD_LENGTH_RATIO = .12;
8700
- const DEFAULT_ARROWHEAD_WIDTH_RATIO = .5;
8701
- const DEFAULT_ZIGZAG_START = .55;
8702
- const DEFAULT_ZIGZAG_END = .45;
8703
- const DEFAULT_ZIGZAG_AMPLITUDE_RATIO = .07;
8704
- /**
8705
- * Default options for the tactical arrow.
8706
- */
8707
- const DEFAULT_TACTICAL_ARROW_OPTIONS = {
8708
- arrowheadLengthRatio: DEFAULT_ARROWHEAD_LENGTH_RATIO,
8709
- arrowheadWidthRatio: DEFAULT_ARROWHEAD_WIDTH_RATIO,
8710
- zigzagStart: DEFAULT_ZIGZAG_START,
8711
- zigzagEnd: DEFAULT_ZIGZAG_END,
8712
- zigzagAmplitudeRatio: DEFAULT_ZIGZAG_AMPLITUDE_RATIO
8713
- };
8714
- const SEARCH_AREA_METADATA = {
8715
- id: "search-area",
8716
- name: "Search Area",
8717
- description: "Search area graphic with paired lightning-style arrows.",
8718
- entity: "Maneuver Areas",
8719
- entityType: "Search Area",
8720
- value: "152200",
8721
- minCoordinates: 3,
8722
- maxCoordinates: 3,
8723
- geometry: "area",
8724
- geometryTypes: ["MultiLineString", "MultiPolygon"],
8725
- drawRule: "Area21",
8726
- params: [
8727
- {
8728
- key: "arrowheadLengthRatio",
8729
- label: "Arrowhead length",
8730
- description: "Arrowhead length as a ratio of the total leg length",
8731
- type: "number",
8732
- min: 0,
8733
- max: .5,
8734
- step: .01
8735
- },
8736
- {
8737
- key: "arrowheadWidthRatio",
8738
- label: "Arrowhead width",
8739
- description: "Arrowhead base width as a multiple of the arrowhead length",
8740
- type: "number",
8741
- min: .05,
8742
- max: 2,
8743
- step: .1
8744
- },
8745
- {
8746
- key: "zigzagStart",
8747
- label: "Zigzag start",
8748
- description: "Point along the leg where the zigzag begins (0-1).",
8749
- type: "number",
8750
- min: 0,
8751
- max: 1,
8752
- step: .05
8753
- },
8754
- {
8755
- key: "zigzagEnd",
8756
- label: "Zigzag end",
8757
- description: "Point along the leg where the zigzag returns (0-1).",
8758
- type: "number",
8759
- min: 0,
8760
- max: 1,
8761
- step: .05
8762
- },
8763
- {
8764
- key: "zigzagAmplitudeRatio",
8765
- label: "Zigzag amplitude",
8766
- description: "Perpendicular kink offset as a ratio of leg length.",
8767
- type: "number",
8768
- min: 0,
8769
- max: .3,
8770
- step: .01
8771
- }
8772
- ]
8773
- };
8774
- /**
8775
- * Generates a GeoJSON FeatureCollection of a tactical "lightning" arrow.
8776
- * Handles internal projection to maintain correct perpendicularity.
8777
- *
8778
- * @param coordinates - [Apex Point (PT1), Top Tail (PT2), Bottom Tail (PT3)]
8779
- * @param options - Geometric configuration parameters
8780
- * @returns A FeatureCollection containing a MultiLineString for bolts and a MultiPolygon for heads
8781
- */
8782
- function createSearchArea(coordinates, options = {}) {
8783
- 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;
8784
- const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
8785
- const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
8786
- const safeZigzagStart = clamp01(zigzagStart);
8787
- const safeZigzagEnd = clamp01(zigzagEnd);
8788
- const safeZigzagAmplitudeRatio = Math.max(0, zigzagAmplitudeRatio);
8789
- const [c0, c1, c2] = coordinates;
8790
- const [p1, p2, p3] = [
8791
- project(c0[0], c0[1]),
8792
- project(c1[0], c1[1]),
8793
- project(c2[0], c2[1])
8794
- ];
8795
- const shaftSegments = [];
8796
- const headPolygons = [];
8797
- const addLeg = (origin, tip, opposite) => {
8798
- const v = vecSub(tip, origin);
8799
- const totalLen = vecMag(v);
8800
- if (totalLen < 1e-6) return;
8801
- const u = vecNorm(v);
8802
- let n = [-u[1], u[0]];
8803
- const toOpp = vecSub(opposite, origin);
8804
- if (vecDot(n, toOpp) > 0) n = vecScale(n, -1);
8805
- const offset = totalLen * safeZigzagAmplitudeRatio;
8806
- const ptA = vecAdd(vecAdd(origin, vecScale(v, safeZigzagStart)), vecScale(n, offset));
8807
- const ptB = vecAdd(vecAdd(origin, vecScale(v, safeZigzagEnd)), vecScale(n, -offset));
8808
- const fv = vecSub(tip, ptB);
8809
- const fLen = vecMag(fv);
8810
- let fu = vecNorm(fv);
8811
- if (fLen < 1e-6) fu = u;
8812
- const headLen = totalLen * safeArrowheadLengthRatio;
8813
- const pBase = vecSub(tip, vecScale(fu, headLen));
8814
- const wingWidth = headLen * safeArrowheadWidthRatio;
8815
- const perp = [-fu[1], fu[0]];
8816
- const pWing1 = vecAdd(pBase, vecScale(perp, wingWidth));
8817
- const pWing2 = vecSub(pBase, vecScale(perp, wingWidth));
8818
- shaftSegments.push([
8819
- origin,
8820
- ptA,
8821
- ptB,
8822
- pBase
8823
- ].map((p) => unproject(p[0], p[1])));
8824
- headPolygons.push([[
8825
- tip,
8826
- pWing1,
8827
- pWing2,
8828
- tip
8829
- ].map((p) => unproject(p[0], p[1]))]);
8830
- };
8831
- addLeg(p1, p2, p3);
8832
- addLeg(p1, p3, p2);
8833
- const features = [];
8834
- if (shaftSegments.length > 0) features.push({
8835
- type: "Feature",
8836
- properties: { part: "shaft" },
8837
- geometry: {
8838
- type: "MultiLineString",
8839
- coordinates: shaftSegments
8840
- }
8841
- });
8842
- if (headPolygons.length > 0) features.push({
8843
- type: "Feature",
8844
- properties: {
8845
- part: "head",
8846
- fill: true
8847
- },
8848
- geometry: {
8849
- type: "MultiPolygon",
8850
- coordinates: headPolygons
8851
- }
8852
- });
8853
- return {
8854
- type: "FeatureCollection",
8855
- features
8856
- };
8857
- }
8858
- const SEARCH_AREA = defineControlMeasure({
8859
- metadata: SEARCH_AREA_METADATA,
8860
- generator: (controlPoints, options) => createSearchArea(asThreePoints(controlPoints), options),
8861
- defaultOptions: DEFAULT_TACTICAL_ARROW_OPTIONS,
8862
- rule: searchAreaDrawRule
8863
- });
8864
- //#endregion
8865
10054
  //#region src/generators/cm15-maneuver-areas/strongPoint.ts
8866
10055
  const DEFAULT_SMOOTH_RESOLUTION = 16;
8867
10056
  /** Default options for the Strong Point control measure. */
@@ -9421,6 +10610,37 @@ function rotate(vector, angle) {
9421
10610
  const sin = Math.sin(angle);
9422
10611
  return [vector[0] * cos - vector[1] * sin, vector[0] * sin + vector[1] * cos];
9423
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
+ }
9424
10644
  //#endregion
9425
10645
  //#region src/registry.ts
9426
10646
  /**
@@ -9439,12 +10659,16 @@ const DEFINITIONS = {
9439
10659
  "battle-position": BATTLE_POSITION,
9440
10660
  "strong-point": STRONG_POINT,
9441
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,
9442
10665
  "principal-direction-of-fire": PRINCIPAL_DIRECTION_OF_FIRE,
9443
10666
  "final-protective-fire-left": FINAL_PROTECTIVE_FIRE_LEFT,
9444
10667
  "final-protective-fire-right": FINAL_PROTECTIVE_FIRE_RIGHT,
9445
10668
  "search-area": SEARCH_AREA,
9446
10669
  encirclement: ENCIRCLEMENT,
9447
10670
  area: AREA,
10671
+ "area-defense": AREA_DEFENSE,
9448
10672
  "assembly-area": ASSEMBLY_AREA,
9449
10673
  "joint-tactical-action-area": JOINT_TACTICAL_ACTION_AREA,
9450
10674
  "submarine-action-area": SUBMARINE_ACTION_AREA,
@@ -9484,22 +10708,41 @@ const DEFINITIONS = {
9484
10708
  bypass: BYPASS,
9485
10709
  canalize: CANALIZE,
9486
10710
  clear: CLEAR,
10711
+ cover: COVER,
9487
10712
  delay: DELAY,
10713
+ guard: GUARD,
9488
10714
  isolate: ISOLATE,
10715
+ screen: SCREEN,
9489
10716
  "antitank-ditch-under-construction": ANTITANK_DITCH_UNDER_CONSTRUCTION,
9490
10717
  "antitank-ditch-completed": ANTITANK_DITCH_COMPLETED,
9491
10718
  "antitank-wall": ANTITANK_WALL,
9492
10719
  "fortified-line": FORTIFIED_LINE,
9493
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
+ }),
9494
10742
  block: BLOCK,
9495
10743
  disrupt: DISRUPT,
9496
10744
  fix: FIX,
9497
- turn: defineControlMeasure({
9498
- metadata: TURN_METADATA,
9499
- generator: createTurn,
9500
- defaultOptions: DEFAULT_TURN_OPTIONS,
9501
- rule: turnDrawRule
9502
- }),
10745
+ turn: TURN,
9503
10746
  "obstacle-bypass-easy": OBSTACLE_BYPASS_EASY,
9504
10747
  "obstacle-bypass-difficult": OBSTACLE_BYPASS_DIFFICULT,
9505
10748
  "obstacle-bypass-impossible": OBSTACLE_BYPASS_IMPOSSIBLE
@@ -9877,4 +11120,4 @@ function assertNever(value) {
9877
11120
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
9878
11121
  }
9879
11122
  //#endregion
9880
- 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 };