@orbat-mapper/control-measures 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -280,7 +280,7 @@ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
280
280
  /**
281
281
  * Creates the definition-owned rear-/shaft-width handle pair shared by every
282
282
  * variable-width arrow body. Callers supply only how their own geometry maps
283
- * onto {@link WidthHandleGeometry}; placement (the two ends of the left edge)
283
+ * onto {@link WidthHandleGeometry}; placement (the two ends of the right edge)
284
284
  * and the drag math (perpendicular distance from the dragged point to the
285
285
  * matching centerline segment, over the reference half-width) live here once.
286
286
  */
@@ -288,15 +288,15 @@ function createWidthOptionHandles(config) {
288
288
  return {
289
289
  get(controlPoints, options) {
290
290
  const geometry = config.geometry(controlPoints, options);
291
- const rearLeft = geometry?.leftEdge[0];
292
- const neckLeft = geometry?.leftEdge.at(-1);
293
- if (!rearLeft || !neckLeft) return [];
291
+ const rearRight = geometry?.rightEdge[0];
292
+ const neckRight = geometry?.rightEdge.at(-1);
293
+ if (!rearRight || !neckRight) return [];
294
294
  return [{
295
295
  id: REAR_WIDTH_OPTION_HANDLE_ID,
296
- position: unproject(rearLeft[0], rearLeft[1])
296
+ position: unproject(rearRight[0], rearRight[1])
297
297
  }, {
298
298
  id: SHAFT_WIDTH_OPTION_HANDLE_ID,
299
- position: unproject(neckLeft[0], neckLeft[1])
299
+ position: unproject(neckRight[0], neckRight[1])
300
300
  }];
301
301
  },
302
302
  drag({ controlPoints, options, handleId, position }) {
@@ -328,7 +328,7 @@ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) =>
328
328
  if (!geometry || !outerLeft) return null;
329
329
  return {
330
330
  spine: geometry.shaftCenterline,
331
- leftEdge: geometry.shaftLeft,
331
+ rightEdge: geometry.shaftRight,
332
332
  referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
333
333
  };
334
334
  },
@@ -1349,6 +1349,23 @@ const line32DrawRule = {
1349
1349
  }
1350
1350
  };
1351
1351
  //#endregion
1352
+ //#region src/draw-rules/polyline1.ts
1353
+ /**
1354
+ * Polyline1 anchors for the Infiltration Lane.
1355
+ *
1356
+ * PT1/PT2 are the two user clicks and define the invisible lane centerline. PT3 is an
1357
+ * automatically derived, editable width handle on the perpendicular through
1358
+ * its midpoint. It rests on one parallel boundary, so its signed distance
1359
+ * from PT1/PT2 is the lane half-width.
1360
+ */
1361
+ const polyline1DrawRule = createMidpointPerpendicularDrawRule({
1362
+ id: "polyline1:infiltration-lane",
1363
+ defaultDistanceRatio: .18,
1364
+ minimumUserPoints: 2,
1365
+ minimumPreviewPoints: 2,
1366
+ normal: "left"
1367
+ });
1368
+ //#endregion
1352
1369
  //#region src/draw-rules/area8.ts
1353
1370
  function derive$3(points) {
1354
1371
  if (points.length < 2) return clonePositions(points);
@@ -1697,6 +1714,16 @@ const ECHELON_PARAM_OPTIONS = [{
1697
1714
  label: spec.label,
1698
1715
  value
1699
1716
  }))];
1717
+ /**
1718
+ * Effective echelon glyph height in meters. Screen-anchored when a pixel size
1719
+ * is supplied with a live resolution; ground-anchored (meters) otherwise.
1720
+ * Clamped strictly positive.
1721
+ */
1722
+ function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
1723
+ let h = echelonSize;
1724
+ if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
1725
+ return Math.max(EPSILON, h);
1726
+ }
1700
1727
  function resolveEchelon(echelon) {
1701
1728
  const key = echelon.trim().toLowerCase();
1702
1729
  if (key.length === 0) return void 0;
@@ -2677,6 +2704,26 @@ function labelRotationAlong(dir) {
2677
2704
  const rendered = -Math.atan2(dir[1], dir[0]);
2678
2705
  return normalizeRadians(Math.PI - keepTextLeftToRight(rendered));
2679
2706
  }
2707
+ /** Resolves two echelon-unit rows from a caller-selected set of amplifiers. */
2708
+ function unitLabelsFromAmplifiers(amplifiers, fields) {
2709
+ return [{
2710
+ side: 1,
2711
+ designator: amplifiers[fields.unit1.designator] ?? "",
2712
+ country: amplifiers[fields.unit1.country] ?? ""
2713
+ }, {
2714
+ side: -1,
2715
+ designator: amplifiers[fields.unit2.designator] ?? "",
2716
+ country: amplifiers[fields.unit2.country] ?? ""
2717
+ }];
2718
+ }
2719
+ /** Text size shared by an echelon glyph and its associated unit labels. */
2720
+ function echelonUnitLabelSize(echelonHeight, echelonSizePixels, metersPerPixel) {
2721
+ const screenAnchored = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0;
2722
+ return {
2723
+ properties: screenAnchored ? { textSizePixels: echelonSizePixels } : { textSizeMeters: echelonHeight },
2724
+ screenAnchored
2725
+ };
2726
+ }
2680
2727
  /**
2681
2728
  * Emits a single combined `T (AS)` label per unit side (APP-6/MIL-STD-2525):
2682
2729
  * designator and country code share one text row rather than stacking.
@@ -2693,6 +2740,43 @@ function pushUnitLabels(out, frame, units) {
2693
2740
  }
2694
2741
  }
2695
2742
  /**
2743
+ * Default clearance between an echelon glyph and its unit labels, as a ratio of
2744
+ * the glyph height — the value Boundary declares as its `labelPadding` default,
2745
+ * shared so other echelon-carrying lines land on the same spacing.
2746
+ */
2747
+ const ECHELON_UNIT_LABEL_PADDING = .4;
2748
+ /**
2749
+ * Emits Boundary-style unit labels around echelon groups. Along-line groups
2750
+ * straddle the line; standard-orientation groups on north/south segments form
2751
+ * one row crossing it, anchored outward from the glyph.
2752
+ */
2753
+ function pushEchelonUnitLabels(out, groups, units, echelonHeight, labelPadding, labelSize) {
2754
+ for (const { center, along, perp, rotateGroup, readingAxis, glyphWidth } of groups) {
2755
+ if (rotateGroup) {
2756
+ const sign = vecDot(perp, readingAxis) >= 0 ? 1 : -1;
2757
+ const clearance = glyphWidth / 2 + Math.max(0, labelPadding) * echelonHeight;
2758
+ pushUnitLabels(out, {
2759
+ center,
2760
+ perp: vecScale(readingAxis, sign),
2761
+ offset: clearance,
2762
+ rotation: labelRotationAlong(readingAxis),
2763
+ labelSize
2764
+ }, units.map((unit) => ({
2765
+ ...unit,
2766
+ textAnchor: unit.side === sign ? "start" : "end"
2767
+ })));
2768
+ continue;
2769
+ }
2770
+ pushUnitLabels(out, {
2771
+ center,
2772
+ perp,
2773
+ offset: (.45 + Math.max(0, labelPadding)) * echelonHeight,
2774
+ rotation: labelRotationAlong(along),
2775
+ labelSize
2776
+ }, units);
2777
+ }
2778
+ }
2779
+ /**
2696
2780
  * Walks `verts` (projected meters) into non-degenerate {@link LineSegment}s,
2697
2781
  * skipping coincident vertices (ADR-0014, silent) the same way Boundary's
2698
2782
  * gap-carving does. Empty or fully-degenerate input yields `totalLength: 0`
@@ -2725,6 +2809,15 @@ function clampLinePosition(value, fallback) {
2725
2809
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2726
2810
  }
2727
2811
  /**
2812
+ * Resolves a label/group repetition count to a positive whole number. Takes
2813
+ * `unknown` because the params panel's `visibleWhen` callbacks receive loosely
2814
+ * typed options; non-finite input falls back to a single group.
2815
+ */
2816
+ function resolveLabelRepetitions(value) {
2817
+ const numeric = Number(value ?? 1);
2818
+ return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
2819
+ }
2820
+ /**
2728
2821
  * Resolves the point at `fraction` (clamped to `[0, 1]`) of `totalLength`
2729
2822
  * along `segments`, walking cumulative arc length exactly like Boundary's
2730
2823
  * label-group placement: the segment containing the target distance absorbs
@@ -3466,14 +3559,14 @@ const DEFAULT_ARROWHEAD_WIDTH_RATIO$1 = 1.15;
3466
3559
  const DEFAULT_BOW_TIE_LENGTH_RATIO = .1;
3467
3560
  const DEFAULT_BOW_TIE_WIDTH_RATIO = .5;
3468
3561
  const DEFAULT_BOW_TIE_POSITION = 1 / 3;
3469
- const DEFAULT_LABEL_POSITION = 2 / 3;
3562
+ const DEFAULT_LABEL_POSITION$1 = 2 / 3;
3470
3563
  const DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS = {
3471
3564
  arrowheadLengthRatio: DEFAULT_ARROWHEAD_LENGTH_RATIO$1,
3472
3565
  arrowheadWidthRatio: DEFAULT_ARROWHEAD_WIDTH_RATIO$1,
3473
3566
  bowTieLengthRatio: DEFAULT_BOW_TIE_LENGTH_RATIO,
3474
3567
  bowTieWidthRatio: DEFAULT_BOW_TIE_WIDTH_RATIO,
3475
3568
  bowTiePosition: DEFAULT_BOW_TIE_POSITION,
3476
- labelPosition: DEFAULT_LABEL_POSITION,
3569
+ labelPosition: DEFAULT_LABEL_POSITION$1,
3477
3570
  labelPadding: 0,
3478
3571
  smooth: false,
3479
3572
  smoothResolution: 12
@@ -3620,7 +3713,7 @@ function createDirectionOfAttackAviation(coordinates, options = {}, textAmplifie
3620
3713
  };
3621
3714
  const bowTieLength = Math.min(totalLength, totalLength * nonNegative(resolved.bowTieLengthRatio, DEFAULT_BOW_TIE_LENGTH_RATIO));
3622
3715
  const bowTiePosition = clampLinePosition(resolved.bowTiePosition, DEFAULT_BOW_TIE_POSITION);
3623
- const labelPosition = clampLinePosition(resolved.labelPosition, DEFAULT_LABEL_POSITION);
3716
+ const labelPosition = clampLinePosition(resolved.labelPosition, DEFAULT_LABEL_POSITION$1);
3624
3717
  const bowTieCenterDistance = totalLength * bowTiePosition;
3625
3718
  const bowTieStartDistance = Math.max(0, bowTieCenterDistance - bowTieLength / 2);
3626
3719
  const bowTieEndDistance = Math.min(totalLength, bowTieCenterDistance + bowTieLength / 2);
@@ -3906,16 +3999,6 @@ function pointOnRing(segs, target) {
3906
3999
  seg
3907
4000
  };
3908
4001
  }
3909
- /**
3910
- * Effective echelon glyph height in meters. Screen-anchored when a pixel size
3911
- * is supplied with a live resolution; ground-anchored (meters) otherwise.
3912
- * Clamped strictly positive.
3913
- */
3914
- function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
3915
- let h = echelonSize;
3916
- if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
3917
- return Math.max(EPSILON, h);
3918
- }
3919
4002
  /** Wraps `x` into `[0, 1)`, treating `x` as a fraction (matches the arc-length wrap below). */
3920
4003
  function wrap01(x) {
3921
4004
  return (x % 1 + 1) % 1;
@@ -4004,7 +4087,7 @@ const BAND_SAMPLE_FRACTIONS = [
4004
4087
  * rerender); striding holds it to a fixed multiple of one ring scan. Rings
4005
4088
  * with no more bands than this are still probed exhaustively. */
4006
4089
  const MAX_SAMPLED_BANDS = 48;
4007
- const DEFAULT_SMOOTH_RESOLUTION$9 = 16;
4090
+ const DEFAULT_SMOOTH_RESOLUTION$8 = 16;
4008
4091
  /**
4009
4092
  * Composes an area's main label text from a `prefix` (e.g. `"AO"`) and a
4010
4093
  * `designation` (e.g. `"GREEN"`), mirroring Phase Line's "PL" precedent: a
@@ -4404,8 +4487,8 @@ function boundaryFeature(verts, gaps) {
4404
4487
  * common (no `N`) case is unaffected.
4405
4488
  */
4406
4489
  function labeledAreaFeatures(positions, options, texts, amplifierPlacements, context) {
4407
- const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$9, ...labelOptions } = options;
4408
- const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$9);
4490
+ const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$8, ...labelOptions } = options;
4491
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$8);
4409
4492
  const labelFeatures = [];
4410
4493
  const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements, void 0, context))];
4411
4494
  features.push(...labelFeatures);
@@ -5122,7 +5205,7 @@ const TARGET_AREA_OF_INTEREST = defineControlMeasure({
5122
5205
  });
5123
5206
  //#endregion
5124
5207
  //#region src/generators/cm12-command-control-areas/airfield-zone.ts
5125
- const DEFAULT_SMOOTH_RESOLUTION$8 = 16;
5208
+ const DEFAULT_SMOOTH_RESOLUTION$7 = 16;
5126
5209
  /** Long runway line's tilt off the horizontal, in radians (+15°). */
5127
5210
  const LONG_LINE_ANGLE = 15 * Math.PI / 180;
5128
5211
  /** Short runway line's tilt off the horizontal, in radians (+50°). */
@@ -5137,7 +5220,7 @@ const H_LABEL_OFFSET_RATIO = .75;
5137
5220
  */
5138
5221
  const DEFAULT_AIRFIELD_ZONE_OPTIONS = {
5139
5222
  smooth: true,
5140
- smoothResolution: DEFAULT_SMOOTH_RESOLUTION$8,
5223
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$7,
5141
5224
  glyphSizeRatio: .55
5142
5225
  };
5143
5226
  const AIRFIELD_ZONE_METADATA = {
@@ -5195,7 +5278,7 @@ const AIRFIELD_ZONE_METADATA = {
5195
5278
  */
5196
5279
  function createAirfieldZone(positions, options = {}, textAmplifiers = {}, context = {}) {
5197
5280
  const { smooth = DEFAULT_AIRFIELD_ZONE_OPTIONS.smooth, smoothResolution = DEFAULT_AIRFIELD_ZONE_OPTIONS.smoothResolution, glyphSizeRatio = DEFAULT_AIRFIELD_ZONE_OPTIONS.glyphSizeRatio, ...labelOptions } = options;
5198
- const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$8);
5281
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
5199
5282
  const enyLabels = [];
5200
5283
  const features = [boundaryFeature(verts, textAmplifiers.N ? pushAreaLabels(enyLabels, verts, { hostile: textAmplifiers.N }, labelOptions, context.amplifierPlacements, void 0, context) : [])];
5201
5284
  let minX = Infinity;
@@ -5253,7 +5336,7 @@ const AIRFIELD_ZONE = defineControlMeasure({
5253
5336
  });
5254
5337
  //#endregion
5255
5338
  //#region src/generators/cm12-command-control-areas/echelon-labeled-area.ts
5256
- const DEFAULT_SMOOTH_RESOLUTION$7 = 16;
5339
+ const DEFAULT_SMOOTH_RESOLUTION$6 = 16;
5257
5340
  /** Default options shared by Base Camp and Guerrilla Base — echelon off, boundary rounded. */
5258
5341
  const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
5259
5342
  echelon: "none",
@@ -5262,7 +5345,7 @@ const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
5262
5345
  echelonPadding: .3,
5263
5346
  echelonPosition: 0,
5264
5347
  smooth: true,
5265
- smoothResolution: DEFAULT_SMOOTH_RESOLUTION$7
5348
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$6
5266
5349
  };
5267
5350
  /**
5268
5351
  * Builds the shared Base Camp / Guerrilla Base geometry: a closed area
@@ -5276,7 +5359,7 @@ const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
5276
5359
  function echelonLabeledAreaFeatures(positions, options, texts, amplifierPlacements, context) {
5277
5360
  const { echelon = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.echelon, echelonSize = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.echelonPadding, echelonPosition = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.echelonPosition, echelonAnchor, metersPerPixel, smooth = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.smooth, smoothResolution = DEFAULT_ECHELON_LABELED_AREA_OPTIONS.smoothResolution, ...labelOptions } = options;
5278
5361
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
5279
- const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
5362
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6);
5280
5363
  const { strokes, fills, gaps: echelonGaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
5281
5364
  const labelFeatures = [];
5282
5365
  const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements, void 0, context);
@@ -6237,7 +6320,7 @@ const ATTACK_HELICOPTER = defineControlMeasure({
6237
6320
  });
6238
6321
  //#endregion
6239
6322
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
6240
- const DEFAULT_SMOOTH_RESOLUTION$6 = 16;
6323
+ const DEFAULT_SMOOTH_RESOLUTION$5 = 16;
6241
6324
  /** Default options for the Battle Position control measure. */
6242
6325
  const DEFAULT_BATTLE_POSITION_OPTIONS = {
6243
6326
  echelon: "battalion",
@@ -6246,7 +6329,7 @@ const DEFAULT_BATTLE_POSITION_OPTIONS = {
6246
6329
  echelonPadding: .3,
6247
6330
  echelonPosition: 0,
6248
6331
  smooth: false,
6249
- smoothResolution: DEFAULT_SMOOTH_RESOLUTION$6
6332
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$5
6250
6333
  };
6251
6334
  const BATTLE_POSITION_METADATA = {
6252
6335
  id: "battle-position",
@@ -6288,7 +6371,7 @@ const BATTLE_POSITION_METADATA = {
6288
6371
  function battlePositionAreaFeatures(positions, options, textAmplifiers, context, variant = {}) {
6289
6372
  const { echelon = DEFAULT_BATTLE_POSITION_OPTIONS.echelon, echelonSize = DEFAULT_BATTLE_POSITION_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_BATTLE_POSITION_OPTIONS.echelonPadding, echelonPosition = DEFAULT_BATTLE_POSITION_OPTIONS.echelonPosition, echelonAnchor, metersPerPixel, smooth = DEFAULT_BATTLE_POSITION_OPTIONS.smooth, smoothResolution = DEFAULT_BATTLE_POSITION_OPTIONS.smoothResolution } = options;
6290
6373
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
6291
- const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6);
6374
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$5);
6292
6375
  const { strokes, fills, gaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
6293
6376
  const labelFeatures = [];
6294
6377
  const enyGaps = pushAreaLabels(labelFeatures, verts, composeAreaLabelTexts(textAmplifiers, variant.namePrefix), options, context.amplifierPlacements, void 0, context);
@@ -6862,7 +6945,7 @@ const BRIDGE_OR_GAP = defineControlMeasure({
6862
6945
  });
6863
6946
  //#endregion
6864
6947
  //#region src/generators/cm99-generic-arrows/blockArrow.ts
6865
- const DEFAULT_SMOOTH_RESOLUTION$5 = 12;
6948
+ const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
6866
6949
  const MIN_SMOOTH_RESOLUTION$1 = 2;
6867
6950
  const MAX_SMOOTH_RESOLUTION$1 = 64;
6868
6951
  const DEFAULT_SHAFT_WIDTH_RATIO = .06;
@@ -6875,7 +6958,7 @@ const DEFAULT_BLOCK_ARROW_OPTIONS = {
6875
6958
  arrowheadWidthRatio: .18,
6876
6959
  arrowheadLengthRatio: .22,
6877
6960
  smooth: false,
6878
- smoothResolution: DEFAULT_SMOOTH_RESOLUTION$5,
6961
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4,
6879
6962
  filled: true
6880
6963
  };
6881
6964
  const BLOCK_ARROW_METADATA = {
@@ -7110,6 +7193,7 @@ function calculateBlockArrowGeometry(coordinates, options) {
7110
7193
  ring,
7111
7194
  spine,
7112
7195
  leftSide,
7196
+ rightSide,
7113
7197
  pathLength
7114
7198
  };
7115
7199
  }
@@ -7134,7 +7218,7 @@ const BLOCK_ARROW = defineControlMeasure({
7134
7218
  if (!geometry) return null;
7135
7219
  return {
7136
7220
  spine: geometry.spine,
7137
- leftEdge: geometry.leftSide,
7221
+ rightEdge: geometry.rightSide,
7138
7222
  referenceHalfWidth: geometry.pathLength / 2
7139
7223
  };
7140
7224
  },
@@ -7164,74 +7248,26 @@ function axis1Geometry(coordinates) {
7164
7248
  };
7165
7249
  }
7166
7250
  function normalizeSmoothResolution$1(value) {
7167
- if (!Number.isFinite(value)) return DEFAULT_SMOOTH_RESOLUTION$5;
7251
+ if (!Number.isFinite(value)) return DEFAULT_SMOOTH_RESOLUTION$4;
7168
7252
  return Math.min(MAX_SMOOTH_RESOLUTION$1, Math.max(MIN_SMOOTH_RESOLUTION$1, Math.round(value)));
7169
7253
  }
7170
7254
  //#endregion
7171
- //#region src/generators/cm11-command-control-lines/boundary.ts
7172
- const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
7173
- function resolveLabelRepetitions(value) {
7174
- const numeric = Number(value ?? 1);
7175
- return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
7176
- }
7177
- /** Default options for the Boundary control measure. */
7178
- const DEFAULT_BOUNDARY_OPTIONS = {
7179
- echelon: "battalion",
7180
- echelonSize: 750,
7181
- echelonSizePixels: 16,
7182
- echelonPadding: .3,
7183
- labelRepetitions: 1,
7184
- labelSpacing: 1,
7185
- labelPosition: .5,
7186
- labelPadding: .4,
7187
- labelOrientation: "along",
7188
- smooth: false,
7189
- smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4
7190
- };
7191
- const BOUNDARY_METADATA = {
7192
- id: "boundary",
7193
- name: "Boundary",
7194
- description: "Delineates the area of responsibility between two adjacent units, labeled with the echelon and the designators of the units it separates.",
7195
- entity: "Command and Control Lines",
7196
- entityType: "Boundary",
7197
- value: "110100",
7198
- minCoordinates: 2,
7199
- geometry: "line",
7200
- geometryTypes: [
7201
- "MultiLineString",
7202
- "MultiPolygon",
7203
- "Point"
7204
- ],
7205
- paints: {
7206
- stroke: true,
7207
- fill: "fixed",
7208
- text: true
7209
- },
7210
- drawRule: "Line1",
7211
- params: [
7212
- {
7213
- key: "smooth",
7214
- label: "Smooth",
7215
- description: "Round the boundary's corners by curving it through the control points.",
7216
- type: "boolean"
7217
- },
7218
- {
7219
- key: "smoothResolution",
7220
- presentationTier: "advanced",
7221
- label: "Smooth resolution",
7222
- description: "Number of samples per segment when smooth mode is enabled.",
7223
- type: "number",
7224
- min: 2,
7225
- max: 64,
7226
- step: 1,
7227
- visibleWhen: (opts) => Boolean(opts.smooth)
7228
- },
7255
+ //#region src/internal/line-echelon.ts
7256
+ /**
7257
+ * The echelon and group-distribution controls shared by the line measures that
7258
+ * stamp Field B onto their geometry (Boundary, Generic command and control
7259
+ * line). `subject` names the geometry in the user-facing copy ("line",
7260
+ * "boundary"). Callers spread these into their own `params` and add whatever
7261
+ * else they carry (e.g. Boundary's unit-label padding).
7262
+ */
7263
+ function echelonLineParams(subject) {
7264
+ return [
7229
7265
  {
7230
7266
  key: "echelon",
7231
7267
  presentationTier: "primary",
7232
7268
  semanticRole: "doctrinal",
7233
7269
  label: "Echelon",
7234
- description: "Field B unit-size amplifier, drawn on the line",
7270
+ description: `Field B unit-size amplifier, drawn on the ${subject}`,
7235
7271
  type: "enum",
7236
7272
  options: ECHELON_PARAM_OPTIONS
7237
7273
  },
@@ -7269,7 +7305,7 @@ const BOUNDARY_METADATA = {
7269
7305
  key: "labelRepetitions",
7270
7306
  presentationTier: "advanced",
7271
7307
  label: "Label groups",
7272
- description: "Number of label groups spaced evenly along the whole boundary",
7308
+ description: `Number of label groups spaced evenly along the whole ${subject}`,
7273
7309
  type: "number",
7274
7310
  min: 1,
7275
7311
  max: 5,
@@ -7279,7 +7315,7 @@ const BOUNDARY_METADATA = {
7279
7315
  key: "labelPosition",
7280
7316
  presentationTier: "advanced",
7281
7317
  label: "Label position",
7282
- description: "Position of the label group along the boundary, from the first point (0) to the last (1)",
7318
+ description: `Position of the label group along the ${subject}, from the first point (0) to the last (1)`,
7283
7319
  type: "number",
7284
7320
  min: 0,
7285
7321
  max: 1,
@@ -7297,30 +7333,160 @@ const BOUNDARY_METADATA = {
7297
7333
  step: .05,
7298
7334
  visibleWhen: (opts) => resolveLabelRepetitions(opts.labelRepetitions) > 1
7299
7335
  },
7300
- {
7301
- key: "labelPadding",
7302
- presentationTier: "advanced",
7303
- label: "Label padding",
7304
- description: "Gap between the line/echelon and the unit labels, as a ratio of the echelon height",
7305
- type: "number",
7306
- min: 0,
7307
- max: 2,
7308
- step: .05
7309
- },
7310
7336
  {
7311
7337
  key: "labelOrientation",
7312
7338
  presentationTier: "advanced",
7313
7339
  label: "Label orientation",
7314
- description: "Unit labels along the line, or per MIL-STD-2525E Figure L-10: on mostly north–south segments the label group rotates perpendicular to the line as a single row crossing it.",
7340
+ description: `Orient the label group along the ${subject}, or per MIL-STD-2525E Figure L-10: on mostly north–south segments the group rotates perpendicular to the ${subject} as a single row crossing it.`,
7315
7341
  type: "enum",
7316
7342
  options: [{
7317
7343
  value: "along",
7318
- label: "Along the line"
7344
+ label: `Along the ${subject}`
7319
7345
  }, {
7320
7346
  value: "standard",
7321
7347
  label: "Standard (MIL-STD-2525E)"
7322
7348
  }]
7323
7349
  }
7350
+ ];
7351
+ }
7352
+ /** Along-line position used when a measure leaves `labelPosition` unset. */
7353
+ const DEFAULT_LABEL_POSITION = .5;
7354
+ /** A placement with nothing in it — the result for a degenerate line. */
7355
+ const EMPTY_LINE_ECHELON_PLACEMENT = {
7356
+ strokes: [],
7357
+ fills: [],
7358
+ gaps: [],
7359
+ groups: []
7360
+ };
7361
+ /**
7362
+ * Distributes `labelRepetitions` echelon groups along `verts` and builds the
7363
+ * glyph geometry for each. `spec` may be `undefined` — the groups are still
7364
+ * placed (so a caller can label them) but no glyph or gap is emitted.
7365
+ */
7366
+ function placeEchelonsAlongLine(verts, spec, h, options) {
7367
+ const { labelRepetitions, labelPosition, labelSpacing, labelOrientation, echelonPadding } = options;
7368
+ const { segments, totalLength } = polylineSegments(verts);
7369
+ if (totalLength < 1e-6) return EMPTY_LINE_ECHELON_PLACEMENT;
7370
+ const reps = resolveLabelRepetitions(labelRepetitions);
7371
+ const singleGroupPosition = clampLinePosition(labelPosition, DEFAULT_LABEL_POSITION);
7372
+ const strokes = [];
7373
+ const fills = [];
7374
+ const gaps = [];
7375
+ const groups = [];
7376
+ for (let k = 0; k < reps; k++) {
7377
+ const evenFraction = (k + 1) / (reps + 1);
7378
+ const fraction = Math.min(1, Math.max(0, reps === 1 ? singleGroupPosition : .5 + (evenFraction - .5) * labelSpacing));
7379
+ const target = fraction * totalLength;
7380
+ const { point: center, along, perp } = pointAlongPolyline(segments, totalLength, fraction);
7381
+ const rotateGroup = labelOrientation === "standard" && Math.abs(along[0]) < Math.abs(along[1]);
7382
+ const readingAxis = perp[0] >= 0 ? perp : vecScale(perp, -1);
7383
+ let glyphWidth = 0;
7384
+ if (spec) {
7385
+ const glyph = buildEchelonGlyph(center, rotateGroup ? readingAxis : along, rotateGroup ? [-readingAxis[1], readingAxis[0]] : perp, h, spec);
7386
+ glyphWidth = glyph.width;
7387
+ strokes.push(...glyph.strokes);
7388
+ fills.push(...glyph.fills);
7389
+ const halfGap = (rotateGroup ? h : glyphWidth) / 2 + Math.max(0, echelonPadding) * h;
7390
+ gaps.push({
7391
+ g0: target - halfGap,
7392
+ g1: target + halfGap
7393
+ });
7394
+ }
7395
+ groups.push({
7396
+ center,
7397
+ along,
7398
+ perp,
7399
+ rotateGroup,
7400
+ readingAxis,
7401
+ glyphWidth
7402
+ });
7403
+ }
7404
+ return {
7405
+ strokes,
7406
+ fills,
7407
+ gaps,
7408
+ groups
7409
+ };
7410
+ }
7411
+ //#endregion
7412
+ //#region src/generators/cm11-command-control-lines/boundary.ts
7413
+ /**
7414
+ * Boundary's Field T/AS mapping: unit 1 (`T`/`AS`) on the left-of-travel side,
7415
+ * unit 2 (`T1`/`AS1`) on the right.
7416
+ */
7417
+ const BOUNDARY_UNIT_LABEL_FIELDS = {
7418
+ unit1: {
7419
+ designator: "T",
7420
+ country: "AS"
7421
+ },
7422
+ unit2: {
7423
+ designator: "T1",
7424
+ country: "AS1"
7425
+ }
7426
+ };
7427
+ /** Default options for the Boundary control measure. */
7428
+ const DEFAULT_BOUNDARY_OPTIONS = {
7429
+ echelon: "battalion",
7430
+ echelonSize: 750,
7431
+ echelonSizePixels: 16,
7432
+ echelonPadding: .3,
7433
+ labelRepetitions: 1,
7434
+ labelSpacing: 1,
7435
+ labelPosition: .5,
7436
+ labelPadding: ECHELON_UNIT_LABEL_PADDING,
7437
+ labelOrientation: "along",
7438
+ smooth: false,
7439
+ smoothResolution: 12
7440
+ };
7441
+ const BOUNDARY_METADATA = {
7442
+ id: "boundary",
7443
+ name: "Boundary",
7444
+ description: "Delineates the area of responsibility between two adjacent units, labeled with the echelon and the designators of the units it separates.",
7445
+ entity: "Command and Control Lines",
7446
+ entityType: "Boundary",
7447
+ value: "110100",
7448
+ minCoordinates: 2,
7449
+ geometry: "line",
7450
+ geometryTypes: [
7451
+ "MultiLineString",
7452
+ "MultiPolygon",
7453
+ "Point"
7454
+ ],
7455
+ paints: {
7456
+ stroke: true,
7457
+ fill: "fixed",
7458
+ text: true
7459
+ },
7460
+ drawRule: "Line1",
7461
+ params: [
7462
+ {
7463
+ key: "smooth",
7464
+ label: "Smooth",
7465
+ description: "Round the boundary's corners by curving it through the control points.",
7466
+ type: "boolean"
7467
+ },
7468
+ {
7469
+ key: "smoothResolution",
7470
+ presentationTier: "advanced",
7471
+ label: "Smooth resolution",
7472
+ description: "Number of samples per segment when smooth mode is enabled.",
7473
+ type: "number",
7474
+ min: 2,
7475
+ max: 64,
7476
+ step: 1,
7477
+ visibleWhen: (opts) => Boolean(opts.smooth)
7478
+ },
7479
+ ...echelonLineParams("boundary"),
7480
+ {
7481
+ key: "labelPadding",
7482
+ presentationTier: "advanced",
7483
+ label: "Label padding",
7484
+ description: "Gap between the line/echelon and the unit labels, as a ratio of the echelon height",
7485
+ type: "number",
7486
+ min: 0,
7487
+ max: 2,
7488
+ step: .05
7489
+ }
7324
7490
  ],
7325
7491
  textAmplifiers: [
7326
7492
  {
@@ -7362,80 +7528,22 @@ const BOUNDARY_METADATA = {
7362
7528
  */
7363
7529
  function createBoundary(positions, options = {}, textAmplifiers = {}, context = {}) {
7364
7530
  const { echelon = DEFAULT_BOUNDARY_OPTIONS.echelon, echelonSize = DEFAULT_BOUNDARY_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_BOUNDARY_OPTIONS.echelonPadding, metersPerPixel, labelRepetitions = DEFAULT_BOUNDARY_OPTIONS.labelRepetitions, labelSpacing = DEFAULT_BOUNDARY_OPTIONS.labelSpacing, labelPosition = DEFAULT_BOUNDARY_OPTIONS.labelPosition, labelPadding = DEFAULT_BOUNDARY_OPTIONS.labelPadding, labelOrientation = DEFAULT_BOUNDARY_OPTIONS.labelOrientation, smooth = DEFAULT_BOUNDARY_OPTIONS.smooth, smoothResolution = DEFAULT_BOUNDARY_OPTIONS.smoothResolution } = options;
7365
- const unit1Designator = textAmplifiers.T ?? "";
7366
- const unit1Country = textAmplifiers.AS ?? "";
7367
- const unit2Designator = textAmplifiers.T1 ?? "";
7368
- const unit2Country = textAmplifiers.AS1 ?? "";
7369
- let h = echelonSize;
7370
- if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
7371
- h = Math.max(EPSILON, h);
7372
- const reps = resolveLabelRepetitions(labelRepetitions);
7373
- const singleGroupPosition = clampLinePosition(labelPosition, DEFAULT_BOUNDARY_OPTIONS.labelPosition);
7531
+ const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
7374
7532
  const spec = resolveEchelon(echelon);
7375
- const screenAnchored = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0;
7376
- const labelSize = screenAnchored ? { textSizePixels: echelonSizePixels } : { textSizeMeters: h };
7377
- const rawVerts = positions.map((p) => project(p[0], p[1]));
7378
- const verts = smooth ? catmullRom(rawVerts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$4)) : rawVerts;
7379
- const glyphStrokes = [];
7380
- const glyphFills = [];
7533
+ const { properties: labelSize, screenAnchored } = echelonUnitLabelSize(h, echelonSizePixels, metersPerPixel);
7534
+ const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
7535
+ smooth,
7536
+ smoothResolution
7537
+ }, 12);
7381
7538
  const labelFeatures = [];
7382
- const gaps = [];
7383
- const { segments: segs, totalLength } = polylineSegments(verts);
7384
- if (totalLength >= 1e-6) for (let k = 0; k < reps; k++) {
7385
- const evenFraction = (k + 1) / (reps + 1);
7386
- const fraction = Math.min(1, Math.max(0, reps === 1 ? singleGroupPosition : .5 + (evenFraction - .5) * labelSpacing));
7387
- const target = fraction * totalLength;
7388
- const frame = pointAlongPolyline(segs, totalLength, fraction);
7389
- const center = frame.point;
7390
- const seg = {
7391
- along: frame.along,
7392
- perp: frame.perp
7393
- };
7394
- const units = [{
7395
- side: 1,
7396
- designator: unit1Designator,
7397
- country: unit1Country
7398
- }, {
7399
- side: -1,
7400
- designator: unit2Designator,
7401
- country: unit2Country
7402
- }];
7403
- const rotateGroup = labelOrientation === "standard" && Math.abs(seg.along[0]) < Math.abs(seg.along[1]);
7404
- const readingAxis = seg.perp[0] >= 0 ? seg.perp : vecScale(seg.perp, -1);
7405
- const glyphAlong = rotateGroup ? readingAxis : seg.along;
7406
- const glyphPerp = rotateGroup ? [-readingAxis[1], readingAxis[0]] : seg.perp;
7407
- let glyphWidth = 0;
7408
- if (spec) {
7409
- const { strokes, fills, width } = buildEchelonGlyph(center, glyphAlong, glyphPerp, h, spec);
7410
- glyphWidth = width;
7411
- glyphStrokes.push(...strokes);
7412
- glyphFills.push(...fills);
7413
- const halfGap = (rotateGroup ? h : width) / 2 + Math.max(0, echelonPadding) * h;
7414
- gaps.push({
7415
- g0: target - halfGap,
7416
- g1: target + halfGap
7417
- });
7418
- }
7419
- if (rotateGroup) {
7420
- const sign = vecDot(seg.perp, readingAxis) >= 0 ? 1 : -1;
7421
- const clearance = glyphWidth / 2 + Math.max(0, labelPadding) * h;
7422
- units[0].textAnchor = sign > 0 ? "start" : "end";
7423
- units[1].textAnchor = sign > 0 ? "end" : "start";
7424
- pushUnitLabels(labelFeatures, {
7425
- center,
7426
- perp: vecScale(readingAxis, sign),
7427
- offset: clearance,
7428
- rotation: labelRotationAlong(readingAxis),
7429
- labelSize
7430
- }, units);
7431
- } else pushUnitLabels(labelFeatures, {
7432
- center,
7433
- perp: seg.perp,
7434
- offset: (.45 + Math.max(0, labelPadding)) * h,
7435
- rotation: labelRotationAlong(seg.along),
7436
- labelSize
7437
- }, units);
7438
- }
7539
+ const { strokes: glyphStrokes, fills: glyphFills, gaps, groups } = placeEchelonsAlongLine(verts, spec, h, {
7540
+ labelRepetitions,
7541
+ labelPosition,
7542
+ labelSpacing,
7543
+ labelOrientation,
7544
+ echelonPadding
7545
+ });
7546
+ pushEchelonUnitLabels(labelFeatures, groups, unitLabelsFromAmplifiers(textAmplifiers, BOUNDARY_UNIT_LABEL_FIELDS), h, labelPadding, labelSize);
7439
7547
  const hostileLabelOptions = screenAnchored ? {
7440
7548
  labelSizePixels: echelonSizePixels,
7441
7549
  metersPerPixel,
@@ -7722,8 +7830,26 @@ const ENGINEER_WORK_LINE = defineControlMeasure({
7722
7830
  });
7723
7831
  //#endregion
7724
7832
  //#region src/generators/cm11-command-control-lines/generic-c2-line.ts
7833
+ const GENERIC_C2_UNIT_LABEL_FIELDS = {
7834
+ unit1: {
7835
+ designator: "T1",
7836
+ country: "AS1"
7837
+ },
7838
+ unit2: {
7839
+ designator: "T2",
7840
+ country: "AS2"
7841
+ }
7842
+ };
7725
7843
  /** Default options for the Generic command and control line. */
7726
7844
  const DEFAULT_GENERIC_C2_LINE_OPTIONS = {
7845
+ echelon: "none",
7846
+ echelonSize: 750,
7847
+ echelonSizePixels: 16,
7848
+ echelonPadding: .3,
7849
+ labelRepetitions: 1,
7850
+ labelSpacing: 1,
7851
+ labelPosition: .5,
7852
+ labelOrientation: "along",
7727
7853
  includePrefix: true,
7728
7854
  smooth: false,
7729
7855
  smoothResolution: 12
@@ -7731,22 +7857,28 @@ const DEFAULT_GENERIC_C2_LINE_OPTIONS = {
7731
7857
  const GENERIC_C2_LINE_METADATA = {
7732
7858
  id: "generic-c2-line",
7733
7859
  name: "Generic command and control line",
7734
- description: "Illustrative command-and-control-style line (not part of MIL-STD-2525E) carrying optional additional-information/designation text above it and an effective date-time-group window below it.",
7860
+ description: "Illustrative command-and-control-style line (not part of MIL-STD-2525E) carrying optional echelon glyphs, additional-information/designation text above it, and an effective date-time-group window below it.",
7735
7861
  entity: "Command and Control Lines",
7736
7862
  entityType: "Generic",
7737
7863
  value: "110400",
7738
7864
  minCoordinates: 2,
7739
7865
  geometry: "line",
7740
- geometryTypes: ["LineString", "Point"],
7866
+ geometryTypes: [
7867
+ "LineString",
7868
+ "MultiLineString",
7869
+ "MultiPolygon",
7870
+ "Point"
7871
+ ],
7741
7872
  paints: {
7742
7873
  stroke: true,
7743
- fill: "none",
7874
+ fill: "fixed",
7744
7875
  text: true
7745
7876
  },
7746
7877
  drawRule: "Line1",
7747
7878
  capturesLabelSize: true,
7748
7879
  params: [
7749
7880
  ...SMOOTH_LINE_PARAMS,
7881
+ ...echelonLineParams("line"),
7750
7882
  {
7751
7883
  key: "phaseLineName",
7752
7884
  presentationTier: "primary",
@@ -7765,16 +7897,7 @@ const GENERIC_C2_LINE_METADATA = {
7765
7897
  type: "boolean",
7766
7898
  visibleWhen: (opts) => Boolean(String(opts.phaseLineName ?? "").trim())
7767
7899
  },
7768
- {
7769
- key: "labelPadding",
7770
- presentationTier: "advanced",
7771
- label: "Label padding",
7772
- description: "Extra clearance between the line and its labels, as a ratio of the label height",
7773
- type: "number",
7774
- min: 0,
7775
- max: 2,
7776
- step: .05
7777
- }
7900
+ LINE_LABEL_PADDING_PARAM
7778
7901
  ],
7779
7902
  textAmplifiers: [
7780
7903
  {
@@ -7789,6 +7912,30 @@ const GENERIC_C2_LINE_METADATA = {
7789
7912
  description: "Field T — unique designation of the line, shown above it near each end.",
7790
7913
  maxLength: 21
7791
7914
  },
7915
+ {
7916
+ key: "T1",
7917
+ label: "Unit 1 designation",
7918
+ description: "Field T1 — designation of unit 1 on the left-of-travel side.",
7919
+ maxLength: 32
7920
+ },
7921
+ {
7922
+ key: "AS1",
7923
+ label: "Unit 1 country",
7924
+ description: "Field AS1 — country code of unit 1 on the left-of-travel side.",
7925
+ maxLength: 8
7926
+ },
7927
+ {
7928
+ key: "T2",
7929
+ label: "Unit 2 designation",
7930
+ description: "Field T2 — designation of unit 2 on the right-of-travel side.",
7931
+ maxLength: 32
7932
+ },
7933
+ {
7934
+ key: "AS2",
7935
+ label: "Unit 2 country",
7936
+ description: "Field AS2 — country code of unit 2 on the right-of-travel side.",
7937
+ maxLength: 8
7938
+ },
7792
7939
  {
7793
7940
  key: "W",
7794
7941
  label: "Effective from (DTG)",
@@ -7814,19 +7961,62 @@ const GENERIC_C2_LINE_METADATA = {
7814
7961
  * `base + perp·offset` and the other at `base − perp·offset`.
7815
7962
  */
7816
7963
  function createGenericC2Line(positions, options = {}, textAmplifiers = {}, context = {}) {
7817
- const { phaseLineName, includePrefix = true, smooth, smoothResolution, ...sizeOptions } = options;
7964
+ const { echelon = DEFAULT_GENERIC_C2_LINE_OPTIONS.echelon, echelonSize = DEFAULT_GENERIC_C2_LINE_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_GENERIC_C2_LINE_OPTIONS.echelonPadding, labelRepetitions = DEFAULT_GENERIC_C2_LINE_OPTIONS.labelRepetitions, labelSpacing = DEFAULT_GENERIC_C2_LINE_OPTIONS.labelSpacing, labelPosition = DEFAULT_GENERIC_C2_LINE_OPTIONS.labelPosition, labelOrientation = DEFAULT_GENERIC_C2_LINE_OPTIONS.labelOrientation, phaseLineName, includePrefix = true, smooth, smoothResolution, ...sizeOptions } = options;
7818
7965
  const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
7819
7966
  smooth,
7820
7967
  smoothResolution
7821
7968
  }, 12);
7822
- const features = [{
7969
+ const features = [];
7970
+ const spec = resolveEchelon(echelon);
7971
+ const h = resolveEchelonHeight(echelonSize, echelonSizePixels, sizeOptions.metersPerPixel);
7972
+ const { strokes: glyphStrokes, fills: glyphFills, gaps, groups } = spec ? placeEchelonsAlongLine(verts, spec, h, {
7973
+ labelRepetitions,
7974
+ labelPosition,
7975
+ labelSpacing,
7976
+ labelOrientation,
7977
+ echelonPadding
7978
+ }) : EMPTY_LINE_ECHELON_PLACEMENT;
7979
+ const gappedCoordinates = gaps.length > 0 ? buildGappedLine(verts, gaps) : [];
7980
+ if (gappedCoordinates.length > 0) features.push({
7981
+ type: "Feature",
7982
+ properties: { part: "generic-c2-line" },
7983
+ geometry: {
7984
+ type: "MultiLineString",
7985
+ coordinates: gappedCoordinates
7986
+ }
7987
+ });
7988
+ else if (gaps.length === 0) features.push({
7823
7989
  type: "Feature",
7824
7990
  properties: { part: "generic-c2-line" },
7825
7991
  geometry: {
7826
7992
  type: "LineString",
7827
7993
  coordinates: smooth ? verts.map((v) => unproject(v[0], v[1])) : positions
7828
7994
  }
7829
- }];
7995
+ });
7996
+ if (glyphStrokes.length > 0) features.push({
7997
+ type: "Feature",
7998
+ properties: { part: "echelon" },
7999
+ geometry: {
8000
+ type: "MultiLineString",
8001
+ coordinates: glyphStrokes
8002
+ }
8003
+ });
8004
+ if (glyphFills.length > 0) features.push({
8005
+ type: "Feature",
8006
+ properties: {
8007
+ part: "echelon",
8008
+ fill: true,
8009
+ style: SOLID_ACCENT_FILL
8010
+ },
8011
+ geometry: {
8012
+ type: "MultiPolygon",
8013
+ coordinates: glyphFills.map((ring) => [ring])
8014
+ }
8015
+ });
8016
+ const { properties: unitLabelSize } = echelonUnitLabelSize(h, echelonSizePixels, sizeOptions.metersPerPixel);
8017
+ const unitLabels = [];
8018
+ pushEchelonUnitLabels(unitLabels, groups, unitLabelsFromAmplifiers(textAmplifiers, GENERIC_C2_UNIT_LABEL_FIELDS), h, sizeOptions.labelPadding ?? .4, unitLabelSize);
8019
+ features.push(...unitLabels);
7830
8020
  const above = [textAmplifiers.H, textAmplifiers.T].filter((v) => Boolean(v)).join(" ");
7831
8021
  const below = [textAmplifiers.W, textAmplifiers.W1].filter((v) => Boolean(v)).join(" - ");
7832
8022
  const trimmedPhaseLineName = phaseLineName?.trim();
@@ -12442,6 +12632,98 @@ const FINAL_COORDINATION_LINE = defineControlMeasure({
12442
12632
  }
12443
12633
  });
12444
12634
  //#endregion
12635
+ //#region src/generators/cm14-maneuver-lines/infiltration-lane.ts
12636
+ const DEFAULT_INFILTRATION_LANE_OPTIONS = {};
12637
+ const INFILTRATION_LANE_METADATA = {
12638
+ id: "infiltration-lane",
12639
+ name: "Infiltration Lane",
12640
+ description: "A control measure that coordinates forward and lateral movement of infiltrating units and fixes fire planning responsibilities.",
12641
+ entity: "Maneuver Lines",
12642
+ entityType: "Infiltration Lane",
12643
+ value: "140800",
12644
+ minCoordinates: 3,
12645
+ maxCoordinates: 3,
12646
+ geometry: "line",
12647
+ geometryTypes: ["MultiLineString", "Point"],
12648
+ paints: {
12649
+ stroke: true,
12650
+ fill: "none",
12651
+ text: true
12652
+ },
12653
+ drawRule: "Polyline1",
12654
+ capturesLabelSize: true,
12655
+ textAmplifiers: [{
12656
+ key: "T",
12657
+ label: "Unique designation",
12658
+ description: "Field T — the infiltration lane's unique designation.",
12659
+ placeholder: "GREEN",
12660
+ maxLength: 21
12661
+ }]
12662
+ };
12663
+ /** Creates the two parallel boundaries and centered Field T lane designation. */
12664
+ function createInfiltrationLane(coordinates, options = {}, textAmplifiers = {}) {
12665
+ const [c1, c2, c3] = coordinates;
12666
+ const p1 = project(c1[0], c1[1]);
12667
+ const p2 = project(c2[0], c2[1]);
12668
+ const frame = createProjectedBaselineFrame(p1, p2, {
12669
+ origin: "midpoint",
12670
+ normal: "left"
12671
+ });
12672
+ if (!frame) return {
12673
+ type: "FeatureCollection",
12674
+ features: []
12675
+ };
12676
+ const signedHalfWidth = frame.signedNormalDistance(c3);
12677
+ if (Math.abs(signedHalfWidth) < 1e-6) return {
12678
+ type: "FeatureCollection",
12679
+ features: []
12680
+ };
12681
+ const midpoint = frame.origin;
12682
+ const offset = vecScale(frame.normal, signedHalfWidth);
12683
+ const features = [{
12684
+ type: "Feature",
12685
+ properties: { part: "infiltration-lane" },
12686
+ geometry: {
12687
+ type: "MultiLineString",
12688
+ coordinates: [[vecAdd(p1, offset), vecAdd(p2, offset)], [vecSub(p1, offset), vecSub(p2, offset)]].map((line) => line.map((point) => unproject(point[0], point[1])))
12689
+ }
12690
+ }];
12691
+ if (textAmplifiers.T) features.push({
12692
+ type: "Feature",
12693
+ properties: {
12694
+ part: "label",
12695
+ labelPlacement: false,
12696
+ amplifierField: "T",
12697
+ text: textAmplifiers.T,
12698
+ rotation: labelRotationAlong(frame.direction),
12699
+ ...labelSizeProps(options)
12700
+ },
12701
+ geometry: {
12702
+ type: "Point",
12703
+ coordinates: unproject(midpoint[0], midpoint[1])
12704
+ }
12705
+ });
12706
+ return {
12707
+ type: "FeatureCollection",
12708
+ features
12709
+ };
12710
+ }
12711
+ const INFILTRATION_LANE = defineControlMeasure({
12712
+ metadata: INFILTRATION_LANE_METADATA,
12713
+ generator: createInfiltrationLane,
12714
+ defaultOptions: DEFAULT_INFILTRATION_LANE_OPTIONS,
12715
+ rule: polyline1DrawRule,
12716
+ previewSample: {
12717
+ controlPoints: [
12718
+ [-1, 0],
12719
+ [1, 0],
12720
+ [0, .36]
12721
+ ],
12722
+ textAmplifiers: { T: "GREEN" },
12723
+ options: { labelSize: 100 }
12724
+ }
12725
+ });
12726
+ //#endregion
12445
12727
  //#region src/generators/cm14-maneuver-lines/airhead-line.ts
12446
12728
  /** Clearance between the boundary's southernmost point and the caption, as a
12447
12729
  * ratio of the caption's height. */
@@ -16837,6 +17119,7 @@ const DEFINITIONS = {
16837
17119
  "forward-edge-of-battle-area": FORWARD_EDGE_OF_BATTLE_AREA,
16838
17120
  "bridgehead-line": BRIDGEHEAD_LINE,
16839
17121
  "final-coordination-line": FINAL_COORDINATION_LINE,
17122
+ "infiltration-lane": INFILTRATION_LANE,
16840
17123
  "airhead-line": AIRHEAD_LINE,
16841
17124
  "holding-line": HOLDING_LINE,
16842
17125
  "release-line": RELEASE_LINE,
@@ -17292,4 +17575,4 @@ function assertNever(value) {
17292
17575
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
17293
17576
  }
17294
17577
  //#endregion
17295
- export { DEFAULT_DISRUPT_OPTIONS as $, DEFAULT_STROKE_DASH_CSS_PIXELS as $t, DEFAULT_RETIRE_OPTIONS as A, createBaselineFrame as An, DEFAULT_CONTAIN_OPTIONS as At, DEFAULT_LIMIT_OF_ADVANCE_OPTIONS as B, DEFAULT_AREA_DEFENSE_OPTIONS as Bt, DEFAULT_MAIN_ATTACK_OPTIONS as C, containDrawRule as Cn, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Ct, DEFAULT_WITHDRAW_OPTIONS as D, getMidpointPerpendicularSignedDistance as Dn, DEFAULT_BOUNDARY_OPTIONS as Dt, DEFAULT_WITHDRAW_UNDER_PRESSURE_OPTIONS as E, createMidpointPerpendicularDrawRule as En, DEFAULT_LIGHT_LINE_OPTIONS as Et, DEFAULT_FORTIFIED_LINE_OPTIONS as F, sphericalBearing as Fn, DEFAULT_ANTITANK_DITCH_OPTIONS as Ft, DEFAULT_HOLDING_LINE_OPTIONS as G, canonicalTextAmplifierKey as Gt, DEFAULT_HANDOVER_LINE_OPTIONS as H, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as Ht, DEFAULT_PROBABLE_LINE_OF_DEPLOYMENT_OPTIONS as I, unproject as In, DEFAULT_ASSEMBLY_AREA_OPTIONS as It, DEFAULT_FLOT_OPTIONS as J, DEFAULT_LABEL_HEIGHT_CSS_PIXELS as Jt, DEFAULT_BRIDGEHEAD_LINE_OPTIONS as K, normalizeTextAmplifiers as Kt, PROBABLE_LINE_OF_DEPLOYMENT_DASH as L, EPSILON as Ln, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as Lt, DEFAULT_PENETRATE_OPTIONS as M, computeInitialWidthPoint as Mn, DEFAULT_ATTACK_HELICOPTER_OPTIONS as Mt, DEFAULT_FRONTAL_ATTACK_OPTIONS as N, haversineDistance as Nn, DEFAULT_ATTACK_BY_FIRE_OPTIONS as Nt, DEFAULT_SEIZE_OPTIONS as O, pointOnMidpointPerpendicularAxis as On, DEFAULT_BLOCK_ARROW_OPTIONS as Ot, DEFAULT_FORTIFIED_AREA_OPTIONS as P, project as Pn, DEFAULT_ANTITANK_WALL_OPTIONS as Pt, DEFAULT_ENCIRCLEMENT_OPTIONS as Q, DEFAULT_PORTRAYAL as Qt, DEFAULT_LINE_OF_DEPARTURE_CONTACT_OPTIONS as R, getMetersPerPixel as Rn, DEFAULT_MOBILE_DEFENSE_OPTIONS as Rt, DEFAULT_MINEFIELD_OPTIONS as S, centerRadiusDrawRule as Sn, DEFAULT_BREACH_OPTIONS as St, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as T, computeDefaultMidpointPerpendicularPoint as Tn, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as Tt, DEFAULT_FORWARD_EDGE_OF_BATTLE_AREA_OPTIONS as U, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as Ut, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as V, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as Vt, DEFAULT_RELEASE_LINE_OPTIONS as W, TEXT_AMPLIFIER_FIELDS as Wt, DEFAULT_FIX_OPTIONS as X, DEFAULT_LINE_CAP as Xt, DEFAULT_FIX_MISSION_TASK_OPTIONS as Y, DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS as Yt, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as Z, DEFAULT_LINE_JOIN as Zt, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as _, staticPointDrawRule as _n, DEFAULT_GENERIC_LINE_OPTIONS as _t, DEFINITIONS as a, axis1DrawRule as an, DEFAULT_TACTICAL_ARROW_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as b, disruptDrawRule as bn, DEFAULT_CANALIZE_OPTIONS as bt, getDefaultOptions as c, line26DrawRule as cn, DEFAULT_SUPPORTING_ATTACK_OPTIONS as ct, DEFAULT_TURN_OPTIONS as d, turnDrawRule as dn, RADAR_SEARCH_DOCTRINE_FILL_COLOR as dt, DEFAULT_STROKE_WIDTH_CSS_PIXELS as en, DEFAULT_GUARD_OPTIONS as et, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as f, line1DrawRule as fn, RADAR_SEARCH_DOCTRINE_STROKE_COLOR as ft, SECONDARY_DIRECTION_OF_FIRE_DASH as g, sectorDrawRule as gn, DEFAULT_GENERIC_POLYGON_OPTIONS as gt, DEFAULT_SECONDARY_DIRECTION_OF_FIRE_OPTIONS as h, dynamicPointDrawRule as hn, DEFAULT_GENERIC_RECTANGLE_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, rectangleDrawRule as in, DEFAULT_COVER_OPTIONS as it, DEFAULT_REARWARD_PASSAGE_OF_LINES_OPTIONS as j, calculateMetrics as jn, DEFAULT_BATTLE_POSITION_OPTIONS as jt, DEFAULT_SCREEN_OPTIONS as k, snapToMidpointPerpendicular as kn, DEFAULT_BLOCK_OPTIONS as kt, listControlMeasureMetadata as l, line24DrawRule as ln, DEFAULT_CLEAR_OPTIONS as lt, DEFAULT_STRONG_POINT_OPTIONS as m, attackByFireDrawRule as mn, DEFAULT_GENERIC_SECTOR_OPTIONS as mt, resolveStyleHints as n, DEFAULT_AMBUSH_OPTIONS as nn, DEFAULT_DISENGAGE_OPTIONS as nt, getControlMeasureMetadata as o, supportByFireDrawRule as on, DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS as ot, DEFAULT_RETAIN_OPTIONS as p, ambushDrawRule as pn, DEFAULT_GENERIC_TEXT_OPTIONS as pt, DEFAULT_PHASE_LINE_OPTIONS as q, resolveAmplifierPlacement as qt, CONTROL_MEASURE_IDS as r, DEFAULT_AIRBORNE_ATTACK_OPTIONS as rn, DEFAULT_DELAY_OPTIONS as rt, getControlMeasureMetadataByValue as s, line27DrawRule as sn, DEFAULT_COUNTERATTACK_OPTIONS as st, renderControlMeasure as t, DEFAULT_SYMBOL_COLOR as tn, DEFAULT_DISRUPT_MISSION_TASK_OPTIONS as tt, DEFAULT_TURNING_MOVEMENT_OPTIONS as u, line23DrawRule as un, DEFAULT_RADAR_SEARCH_DOCTRINE_OPTIONS as ut, DEFAULT_PICKUP_ZONE_OPTIONS as v, point12DrawRule as vn, DEFAULT_GENERIC_CIRCLE_OPTIONS as vt, DEFAULT_LANDING_ZONE_OPTIONS as w, area27DrawRule as wn, DEFAULT_GENERIC_C2_LINE_OPTIONS as wt, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as x, blockDrawRule as xn, DEFAULT_BYPASS_OPTIONS as xt, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as y, penetrateDrawRule as yn, DEFAULT_CLASSIC_ARROW_OPTIONS as yt, DEFAULT_LINE_OF_DEPARTURE_OPTIONS as z, roundToFixed as zn, DEFAULT_ENGAGEMENT_AREA_OPTIONS as zt };
17578
+ export { DEFAULT_ENCIRCLEMENT_OPTIONS as $, DEFAULT_PORTRAYAL as $t, DEFAULT_RETIRE_OPTIONS as A, pointOnMidpointPerpendicularAxis as An, DEFAULT_BLOCK_OPTIONS as At, DEFAULT_LIMIT_OF_ADVANCE_OPTIONS as B, getMetersPerPixel as Bn, DEFAULT_ENGAGEMENT_AREA_OPTIONS as Bt, DEFAULT_MAIN_ATTACK_OPTIONS as C, blockDrawRule as Cn, DEFAULT_BREACH_OPTIONS as Ct, DEFAULT_WITHDRAW_OPTIONS as D, computeDefaultMidpointPerpendicularPoint as Dn, DEFAULT_LIGHT_LINE_OPTIONS as Dt, DEFAULT_WITHDRAW_UNDER_PRESSURE_OPTIONS as E, area27DrawRule as En, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as Et, DEFAULT_FORTIFIED_LINE_OPTIONS as F, haversineDistance as Fn, DEFAULT_ANTITANK_WALL_OPTIONS as Ft, DEFAULT_HOLDING_LINE_OPTIONS as G, TEXT_AMPLIFIER_FIELDS as Gt, DEFAULT_HANDOVER_LINE_OPTIONS as H, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as Ht, DEFAULT_PROBABLE_LINE_OF_DEPLOYMENT_OPTIONS as I, project as In, DEFAULT_ANTITANK_DITCH_OPTIONS as It, DEFAULT_PHASE_LINE_OPTIONS as J, resolveAmplifierPlacement as Jt, DEFAULT_INFILTRATION_LANE_OPTIONS as K, canonicalTextAmplifierKey as Kt, PROBABLE_LINE_OF_DEPLOYMENT_DASH as L, sphericalBearing as Ln, DEFAULT_ASSEMBLY_AREA_OPTIONS as Lt, DEFAULT_PENETRATE_OPTIONS as M, createBaselineFrame as Mn, DEFAULT_BATTLE_POSITION_OPTIONS as Mt, DEFAULT_FRONTAL_ATTACK_OPTIONS as N, calculateMetrics as Nn, DEFAULT_ATTACK_HELICOPTER_OPTIONS as Nt, DEFAULT_SEIZE_OPTIONS as O, createMidpointPerpendicularDrawRule as On, DEFAULT_BOUNDARY_OPTIONS as Ot, DEFAULT_FORTIFIED_AREA_OPTIONS as P, computeInitialWidthPoint as Pn, DEFAULT_ATTACK_BY_FIRE_OPTIONS as Pt, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as Q, DEFAULT_LINE_JOIN as Qt, DEFAULT_LINE_OF_DEPARTURE_CONTACT_OPTIONS as R, unproject as Rn, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as Rt, DEFAULT_MINEFIELD_OPTIONS as S, disruptDrawRule as Sn, DEFAULT_BYPASS_OPTIONS as St, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as T, containDrawRule as Tn, DEFAULT_GENERIC_C2_LINE_OPTIONS as Tt, DEFAULT_FORWARD_EDGE_OF_BATTLE_AREA_OPTIONS as U, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as Ut, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as V, roundToFixed as Vn, DEFAULT_AREA_DEFENSE_OPTIONS as Vt, DEFAULT_RELEASE_LINE_OPTIONS as W, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as Wt, DEFAULT_FIX_MISSION_TASK_OPTIONS as X, DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS as Xt, DEFAULT_FLOT_OPTIONS as Y, DEFAULT_LABEL_HEIGHT_CSS_PIXELS as Yt, DEFAULT_FIX_OPTIONS as Z, DEFAULT_LINE_CAP as Zt, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as _, dynamicPointDrawRule as _n, DEFAULT_GENERIC_POLYGON_OPTIONS as _t, DEFINITIONS as a, rectangleDrawRule as an, DEFAULT_COVER_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as b, point12DrawRule as bn, DEFAULT_CLASSIC_ARROW_OPTIONS as bt, getDefaultOptions as c, polyline1DrawRule as cn, DEFAULT_COUNTERATTACK_OPTIONS as ct, DEFAULT_TURN_OPTIONS as d, line24DrawRule as dn, DEFAULT_RADAR_SEARCH_DOCTRINE_OPTIONS as dt, DEFAULT_STROKE_DASH_CSS_PIXELS as en, DEFAULT_DISRUPT_OPTIONS as et, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as f, line23DrawRule as fn, RADAR_SEARCH_DOCTRINE_FILL_COLOR as ft, SECONDARY_DIRECTION_OF_FIRE_DASH as g, attackByFireDrawRule as gn, DEFAULT_GENERIC_RECTANGLE_OPTIONS as gt, DEFAULT_SECONDARY_DIRECTION_OF_FIRE_OPTIONS as h, ambushDrawRule as hn, DEFAULT_GENERIC_SECTOR_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, DEFAULT_AIRBORNE_ATTACK_OPTIONS as in, DEFAULT_DELAY_OPTIONS as it, DEFAULT_REARWARD_PASSAGE_OF_LINES_OPTIONS as j, snapToMidpointPerpendicular as jn, DEFAULT_CONTAIN_OPTIONS as jt, DEFAULT_SCREEN_OPTIONS as k, getMidpointPerpendicularSignedDistance as kn, DEFAULT_BLOCK_ARROW_OPTIONS as kt, listControlMeasureMetadata as l, line27DrawRule as ln, DEFAULT_SUPPORTING_ATTACK_OPTIONS as lt, DEFAULT_STRONG_POINT_OPTIONS as m, line1DrawRule as mn, DEFAULT_GENERIC_TEXT_OPTIONS as mt, resolveStyleHints as n, DEFAULT_SYMBOL_COLOR as nn, DEFAULT_DISRUPT_MISSION_TASK_OPTIONS as nt, getControlMeasureMetadata as o, axis1DrawRule as on, DEFAULT_TACTICAL_ARROW_OPTIONS as ot, DEFAULT_RETAIN_OPTIONS as p, turnDrawRule as pn, RADAR_SEARCH_DOCTRINE_STROKE_COLOR as pt, DEFAULT_BRIDGEHEAD_LINE_OPTIONS as q, normalizeTextAmplifiers as qt, CONTROL_MEASURE_IDS as r, DEFAULT_AMBUSH_OPTIONS as rn, DEFAULT_DISENGAGE_OPTIONS as rt, getControlMeasureMetadataByValue as s, supportByFireDrawRule as sn, DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS as st, renderControlMeasure as t, DEFAULT_STROKE_WIDTH_CSS_PIXELS as tn, DEFAULT_GUARD_OPTIONS as tt, DEFAULT_TURNING_MOVEMENT_OPTIONS as u, line26DrawRule as un, DEFAULT_CLEAR_OPTIONS as ut, DEFAULT_PICKUP_ZONE_OPTIONS as v, sectorDrawRule as vn, DEFAULT_GENERIC_LINE_OPTIONS as vt, DEFAULT_LANDING_ZONE_OPTIONS as w, centerRadiusDrawRule as wn, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as wt, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as x, penetrateDrawRule as xn, DEFAULT_CANALIZE_OPTIONS as xt, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as y, staticPointDrawRule as yn, DEFAULT_GENERIC_CIRCLE_OPTIONS as yt, DEFAULT_LINE_OF_DEPARTURE_OPTIONS as z, EPSILON as zn, DEFAULT_MOBILE_DEFENSE_OPTIONS as zt };