@orbat-mapper/control-measures 0.2.0-alpha.7 → 0.2.0-alpha.9

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.
@@ -206,8 +206,8 @@ const SHAFT_MAX_RATIO = .9;
206
206
  */
207
207
  function processAttackGeometry(coordinates, options = {}) {
208
208
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
209
- const roundedBends = options.roundedBends ?? false;
210
- const bendSegments = options.bendSegments ?? 5;
209
+ const smooth = options.smooth ?? false;
210
+ const smoothResolution = options.smoothResolution ?? 5;
211
211
  const points = coordinates.map((c) => project(c[0], c[1]));
212
212
  const numPoints = points.length;
213
213
  const ptTip = points[0];
@@ -217,9 +217,9 @@ function processAttackGeometry(coordinates, options = {}) {
217
217
  const p = points[i];
218
218
  if (p) spinePoints.push(p);
219
219
  }
220
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, roundedBends, bendSegments) };
220
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, smooth, smoothResolution) };
221
221
  }
222
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, roundedBends, bendSegments) {
222
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoothResolution) {
223
223
  const initialNeck = spine[spine.length - 1];
224
224
  if (!initialNeck) return null;
225
225
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -273,8 +273,8 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, roundedBends
273
273
  ];
274
274
  const fullSpine = [...remainingSpine, shaftEndCenter];
275
275
  return {
276
- shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, roundedBends, bendSegments),
277
- shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, roundedBends, bendSegments),
276
+ shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, smooth, smoothResolution),
277
+ shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, smooth, smoothResolution),
278
278
  headRing,
279
279
  ptTip,
280
280
  ptNeck,
@@ -975,20 +975,20 @@ const ATTACK_SHAFT_PARAMS = [
975
975
  step: .05
976
976
  },
977
977
  {
978
- key: "roundedBends",
979
- label: "Rounded bends",
978
+ key: "smooth",
979
+ label: "Smooth",
980
980
  description: "Round the corners where the shaft changes direction.",
981
981
  type: "boolean"
982
982
  },
983
983
  {
984
- key: "bendSegments",
985
- label: "Bend segments",
986
- description: "Number of segments approximating each rounded bend",
984
+ key: "smoothResolution",
985
+ label: "Smooth resolution",
986
+ description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
987
987
  type: "number",
988
988
  min: 1,
989
989
  max: 20,
990
990
  step: 1,
991
- visibleWhen: (opts) => opts.roundedBends === true
991
+ visibleWhen: (opts) => opts.smooth === true
992
992
  }
993
993
  ];
994
994
  const FIRE_ARROW_PARAMS = [
@@ -1002,8 +1002,8 @@ const FIRE_ARROW_PARAMS = [
1002
1002
  step: .01
1003
1003
  },
1004
1004
  {
1005
- key: "backLineLengthRatio",
1006
- label: "Back line length",
1005
+ key: "rearLineLengthRatio",
1006
+ label: "Rear line length",
1007
1007
  description: "Length of the rear flare lines as a ratio of the head",
1008
1008
  type: "number",
1009
1009
  min: .05,
@@ -1011,8 +1011,8 @@ const FIRE_ARROW_PARAMS = [
1011
1011
  step: .01
1012
1012
  },
1013
1013
  {
1014
- key: "backLineAngle",
1015
- label: "Back line angle",
1014
+ key: "rearLineAngle",
1015
+ label: "Rear line angle",
1016
1016
  description: "Angle of the rear flare lines, in degrees",
1017
1017
  type: "number",
1018
1018
  min: 0,
@@ -1021,8 +1021,8 @@ const FIRE_ARROW_PARAMS = [
1021
1021
  }
1022
1022
  ];
1023
1023
  const FORTIFIED_AREA_SIZE_PARAMS = [{
1024
- key: "sizePixels",
1025
- label: "Size",
1024
+ key: "toothSizePixels",
1025
+ label: "Tooth size",
1026
1026
  description: "Size of the fortified-area teeth, in pixels",
1027
1027
  type: "number",
1028
1028
  min: 5,
@@ -1030,8 +1030,8 @@ const FORTIFIED_AREA_SIZE_PARAMS = [{
1030
1030
  step: 1,
1031
1031
  unit: "px"
1032
1032
  }, {
1033
- key: "size",
1034
- label: "Size",
1033
+ key: "toothSize",
1034
+ label: "Tooth size",
1035
1035
  description: "Size of the fortified-area teeth, in meters",
1036
1036
  type: "number",
1037
1037
  min: 1,
@@ -1098,8 +1098,8 @@ const FORTIFIED_AREA_PARAMS = [
1098
1098
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
1099
1099
  const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
1100
1100
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
1101
- roundedBends: false,
1102
- bendSegments: 5
1101
+ smooth: false,
1102
+ smoothResolution: 5
1103
1103
  };
1104
1104
  const AIRBORNE_ATTACK_METADATA = {
1105
1105
  id: "airborne-attack",
@@ -1194,7 +1194,7 @@ const DEFAULT_AMBUSH_OPTIONS = {
1194
1194
  hatchLengthRatio: .15,
1195
1195
  hatchCount: 5,
1196
1196
  curveDepthRatio: .25,
1197
- resolution: 30
1197
+ arcSegments: 30
1198
1198
  };
1199
1199
  const AMBUSH_METADATA = {
1200
1200
  id: "ambush",
@@ -1255,8 +1255,8 @@ const AMBUSH_METADATA = {
1255
1255
  step: .01
1256
1256
  },
1257
1257
  {
1258
- key: "resolution",
1259
- label: "Resolution",
1258
+ key: "arcSegments",
1259
+ label: "Arc segments",
1260
1260
  description: "Number of segments used to render the curve",
1261
1261
  type: "number",
1262
1262
  min: 4,
@@ -1278,11 +1278,11 @@ const quadBezier = (t, a, b, c) => {
1278
1278
  * @returns GeoJSON FeatureCollection containing LineString components
1279
1279
  */
1280
1280
  function createAmbushSymbol(coordinates, options = {}) {
1281
- const { arrowheadLengthRatio, arrowheadWidthRatio, hatchLengthRatio, hatchCount, curveDepthRatio, resolution } = {
1281
+ const { arrowheadLengthRatio, arrowheadWidthRatio, hatchLengthRatio, hatchCount, curveDepthRatio, arcSegments } = {
1282
1282
  ...DEFAULT_AMBUSH_OPTIONS,
1283
1283
  ...options
1284
1284
  };
1285
- const safeResolution = Math.max(1, Math.floor(resolution));
1285
+ const safeArcSegments = Math.max(1, Math.floor(arcSegments));
1286
1286
  const safeHatchCount = Math.max(0, Math.floor(hatchCount));
1287
1287
  const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
1288
1288
  const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
@@ -1315,8 +1315,8 @@ function createAmbushSymbol(coordinates, options = {}) {
1315
1315
  vecSub(hBase, vecScale(perp, hw))
1316
1316
  ]);
1317
1317
  const curve = [];
1318
- for (let i = 0; i <= safeResolution; i++) {
1319
- const t = i / safeResolution;
1318
+ for (let i = 0; i <= safeArcSegments; i++) {
1319
+ const t = i / safeArcSegments;
1320
1320
  curve.push(quadBezier(t, p2, ctrl, p3));
1321
1321
  }
1322
1322
  lines.push(curve);
@@ -1363,8 +1363,8 @@ const FORTIFIED_PARAMS = [
1363
1363
  visibleWhen: (opts) => Boolean(opts.smooth)
1364
1364
  },
1365
1365
  {
1366
- key: "sizePixels",
1367
- label: "Size",
1366
+ key: "toothSizePixels",
1367
+ label: "Tooth size",
1368
1368
  description: "Side length of the square crenellations",
1369
1369
  type: "number",
1370
1370
  min: 5,
@@ -1373,8 +1373,8 @@ const FORTIFIED_PARAMS = [
1373
1373
  unit: "px"
1374
1374
  },
1375
1375
  {
1376
- key: "size",
1377
- label: "Size",
1376
+ key: "toothSize",
1377
+ label: "Tooth size",
1378
1378
  description: "Side length of the square crenellations",
1379
1379
  type: "number",
1380
1380
  min: 1,
@@ -1623,8 +1623,8 @@ const ANTITANK_WALL = defineControlMeasure({
1623
1623
  //#region src/generators/cm15-maneuver-areas/attackByFire.ts
1624
1624
  const DEFAULT_ATTACK_BY_FIRE_OPTIONS = {
1625
1625
  arrowheadWidthRatio: .3,
1626
- backLineLengthRatio: .3,
1627
- backLineAngle: 45
1626
+ rearLineLengthRatio: .3,
1627
+ rearLineAngle: 45
1628
1628
  };
1629
1629
  const ATTACK_BY_FIRE_METADATA = {
1630
1630
  id: "attack-by-fire",
@@ -1655,8 +1655,8 @@ const ATTACK_BY_FIRE_METADATA = {
1655
1655
  function createAttackByFire(coordinates, options = {}) {
1656
1656
  const featureProps = {};
1657
1657
  const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.arrowheadWidthRatio;
1658
- const backLineLengthRatio = options.backLineLengthRatio ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.backLineLengthRatio;
1659
- const backLineAngle = options.backLineAngle ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.backLineAngle;
1658
+ const rearLineLengthRatio = options.rearLineLengthRatio ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.rearLineLengthRatio;
1659
+ const rearLineAngle = options.rearLineAngle ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.rearLineAngle;
1660
1660
  const c1 = coordinates[0];
1661
1661
  const c2 = coordinates[1];
1662
1662
  const c3 = coordinates[2];
@@ -1682,13 +1682,13 @@ function createAttackByFire(coordinates, options = {}) {
1682
1682
  const lenShaft = Math.abs(signedTipDistance);
1683
1683
  const lenBase = frame.length;
1684
1684
  const dirBase = frame.direction;
1685
- const angleRad = backLineAngle * Math.PI / 180;
1685
+ const angleRad = rearLineAngle * Math.PI / 180;
1686
1686
  const baseAngle = -Math.PI / 2;
1687
1687
  const angleLeft = baseAngle - angleRad;
1688
1688
  const dirBackLeft = [dirBase[0] * Math.cos(angleLeft) - dirBase[1] * Math.sin(angleLeft), dirBase[0] * Math.sin(angleLeft) + dirBase[1] * Math.cos(angleLeft)];
1689
1689
  const angleRight = baseAngle + angleRad;
1690
1690
  const dirBackRight = [dirBase[0] * Math.cos(angleRight) - dirBase[1] * Math.sin(angleRight), dirBase[0] * Math.sin(angleRight) + dirBase[1] * Math.cos(angleRight)];
1691
- const backLen = lenBase * backLineLengthRatio;
1691
+ const backLen = lenBase * rearLineLengthRatio;
1692
1692
  const p2Back = vecAdd(p2, vecScale(dirBackLeft, backLen));
1693
1693
  const p3Back = vecAdd(p3, vecScale(dirBackRight, backLen));
1694
1694
  const headWidth = lenShaft * arrowheadWidthRatio;
@@ -1763,8 +1763,8 @@ const ATTACK_BY_FIRE = defineControlMeasure({
1763
1763
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
1764
1764
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
1765
1765
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
1766
- roundedBends: false,
1767
- bendSegments: 5,
1766
+ smooth: false,
1767
+ smoothResolution: 5,
1768
1768
  symbolHeightRatio: .45,
1769
1769
  triangleSizeRatio: .25,
1770
1770
  bottomBarWidthRatio: 1.5,
@@ -2729,7 +2729,7 @@ const DEFAULT_SMOOTH_RESOLUTION$5 = 12;
2729
2729
  const MIN_SMOOTH_RESOLUTION$1 = 2;
2730
2730
  const MAX_SMOOTH_RESOLUTION$1 = 64;
2731
2731
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
2732
- shaftWidth: .06,
2732
+ shaftWidthRatio: .06,
2733
2733
  arrowheadStyle: "triangle",
2734
2734
  arrowheadWidthRatio: .18,
2735
2735
  arrowheadLengthRatio: .22,
@@ -2751,7 +2751,7 @@ const BLOCK_ARROW_METADATA = {
2751
2751
  drawRule: "Axis1",
2752
2752
  params: [
2753
2753
  {
2754
- key: "shaftWidth",
2754
+ key: "shaftWidthRatio",
2755
2755
  label: "Shaft width",
2756
2756
  description: "Shaft band width as a fraction of the total path length",
2757
2757
  type: "number",
@@ -2844,7 +2844,7 @@ const BLOCK_ARROW_METADATA = {
2844
2844
  * right side of the shaft.
2845
2845
  */
2846
2846
  function createBlockArrow(coordinates, options = {}) {
2847
- const { shaftWidth, arrowheadStyle, arrowheadWidthRatio, arrowheadLengthRatio, smooth, smoothResolution, filled } = {
2847
+ const { shaftWidthRatio, arrowheadStyle, arrowheadWidthRatio, arrowheadLengthRatio, smooth, smoothResolution, filled } = {
2848
2848
  ...DEFAULT_BLOCK_ARROW_OPTIONS,
2849
2849
  ...options
2850
2850
  };
@@ -2857,7 +2857,7 @@ function createBlockArrow(coordinates, options = {}) {
2857
2857
  const { pts, pathLength, tip, dir, perp, segLength } = axis;
2858
2858
  const requestedHeadLen = axis1.headLength ?? pathLength * arrowheadLengthRatio;
2859
2859
  const headLen = Math.min(requestedHeadLen, segLength * .95);
2860
- const halfShaft = pathLength * shaftWidth / 2;
2860
+ const halfShaft = pathLength * shaftWidthRatio / 2;
2861
2861
  const halfHead = axis1.headHalfWidth ?? pathLength * arrowheadWidthRatio / 2;
2862
2862
  const onAxis = (d, side = 0) => [tip[0] - dir[0] * d + perp[0] * side, tip[1] - dir[1] * d + perp[1] * side];
2863
2863
  let shaftEndDist;
@@ -2956,7 +2956,7 @@ const BLOCK_ARROW = defineControlMeasure({
2956
2956
  [-1.5, 0],
2957
2957
  [.95, .4]
2958
2958
  ],
2959
- options: { shaftWidth: .2 }
2959
+ options: { shaftWidthRatio: .2 }
2960
2960
  }
2961
2961
  });
2962
2962
  function axis1Geometry(coordinates) {
@@ -3006,10 +3006,8 @@ const DEFAULT_BOUNDARY_OPTIONS = {
3006
3006
  echelonPadding: .3,
3007
3007
  labelRepetitions: 1,
3008
3008
  labelSpacing: 1,
3009
- unit1Designator: "",
3010
- unit1Country: "",
3011
- unit2Designator: "",
3012
- unit2Country: "",
3009
+ labelPadding: .4,
3010
+ labelOrientation: "along",
3013
3011
  smooth: false,
3014
3012
  smoothResolution: DEFAULT_SMOOTH_RESOLUTION$4
3015
3013
  };
@@ -3100,6 +3098,54 @@ const BOUNDARY_METADATA = {
3100
3098
  max: 2,
3101
3099
  step: .05,
3102
3100
  visibleWhen: (opts) => Number(opts.labelRepetitions ?? 1) > 1
3101
+ },
3102
+ {
3103
+ key: "labelPadding",
3104
+ label: "Label padding",
3105
+ description: "Gap between the line/echelon and the unit labels, as a ratio of the echelon height",
3106
+ type: "number",
3107
+ min: 0,
3108
+ max: 2,
3109
+ step: .05
3110
+ },
3111
+ {
3112
+ key: "labelOrientation",
3113
+ label: "Label orientation",
3114
+ 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.",
3115
+ type: "enum",
3116
+ options: [{
3117
+ value: "along",
3118
+ label: "Along the line"
3119
+ }, {
3120
+ value: "standard",
3121
+ label: "Standard (MIL-STD-2525E)"
3122
+ }]
3123
+ }
3124
+ ],
3125
+ textAmplifiers: [
3126
+ {
3127
+ key: "T",
3128
+ label: "Unit designation",
3129
+ description: "Field T — unique designation of the unit on the left-of-travel side.",
3130
+ maxLength: 32
3131
+ },
3132
+ {
3133
+ key: "AS",
3134
+ label: "Country",
3135
+ description: "Field AS — country code of the unit on the left-of-travel side.",
3136
+ maxLength: 8
3137
+ },
3138
+ {
3139
+ key: "T1",
3140
+ label: "Unit designation (other side)",
3141
+ description: "Field T1 — unique designation of the unit on the right-of-travel side.",
3142
+ maxLength: 32
3143
+ },
3144
+ {
3145
+ key: "AS1",
3146
+ label: "Country (other side)",
3147
+ description: "Field AS1 — country code of the unit on the right-of-travel side.",
3148
+ maxLength: 8
3103
3149
  }
3104
3150
  ]
3105
3151
  };
@@ -3114,30 +3160,33 @@ function labelRotationAlong(dir) {
3114
3160
  const rendered = -Math.atan2(dir[1], dir[0]);
3115
3161
  return normalizeRadians(Math.PI - keepTextLeftToRight(rendered));
3116
3162
  }
3117
- /** Emits the T (designator) and AS (country) labels for both unit sides. */
3163
+ /**
3164
+ * Emits a single combined `T (AS)` label per unit side (APP-6/MIL-STD-2525):
3165
+ * designator and country code share one text row rather than stacking.
3166
+ */
3118
3167
  function pushUnitLabels(out, frame, units) {
3119
- const { center, perp, h, rotation, labelSize } = frame;
3120
- const designatorOffset = h * .85;
3121
- const countryOffset = designatorOffset + h * 1.1;
3122
- const label = (side, offset, text) => {
3168
+ const { center, perp, offset, rotation, labelSize } = frame;
3169
+ for (const { side, designator, country, textAnchor } of units) {
3170
+ let text;
3171
+ if (designator.length > 0 && country.length > 0) text = `${designator} (${country})`;
3172
+ else if (designator.length > 0) text = designator;
3173
+ else if (country.length > 0) text = `(${country})`;
3174
+ else continue;
3123
3175
  const q = vecAdd(center, vecScale(perp, side * offset));
3124
- return {
3176
+ out.push({
3125
3177
  type: "Feature",
3126
3178
  properties: {
3127
3179
  part: "label",
3128
3180
  text,
3129
3181
  rotation,
3182
+ ...textAnchor ? { textAnchor } : {},
3130
3183
  ...labelSize
3131
3184
  },
3132
3185
  geometry: {
3133
3186
  type: "Point",
3134
3187
  coordinates: unproject(q[0], q[1])
3135
3188
  }
3136
- };
3137
- };
3138
- for (const { side, designator, country } of units) {
3139
- if (designator.length > 0) out.push(label(side, designatorOffset, designator));
3140
- if (country.length > 0) out.push(label(side, countryOffset, country));
3189
+ });
3141
3190
  }
3142
3191
  }
3143
3192
  /**
@@ -3147,9 +3196,15 @@ function pushUnitLabels(out, frame, units) {
3147
3196
  * @param positions - Boundary anchor points (≥2); the input contract is
3148
3197
  * enforced at the render seam (ADR-0014).
3149
3198
  * @param options - {@link BoundaryOptions}.
3199
+ * @param textAmplifiers - Normalized text amplifiers (ADR-0027): `T`/`AS` label
3200
+ * unit 1 (left-of-travel side), `T1`/`AS1` label unit 2 (right-of-travel side).
3150
3201
  */
3151
- function createBoundary(positions, options = {}) {
3152
- const { echelon = DEFAULT_BOUNDARY_OPTIONS.echelon, echelonSize = DEFAULT_BOUNDARY_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_BOUNDARY_OPTIONS.echelonPadding, metersPerPixel, labelSizeZoom, labelSizeResolution, labelRepetitions = DEFAULT_BOUNDARY_OPTIONS.labelRepetitions, labelSpacing = DEFAULT_BOUNDARY_OPTIONS.labelSpacing, unit1Designator = "", unit1Country = "", unit2Designator = "", unit2Country = "", smooth = DEFAULT_BOUNDARY_OPTIONS.smooth, smoothResolution = DEFAULT_BOUNDARY_OPTIONS.smoothResolution } = options;
3202
+ function createBoundary(positions, options = {}, textAmplifiers = {}) {
3203
+ const { echelon = DEFAULT_BOUNDARY_OPTIONS.echelon, echelonSize = DEFAULT_BOUNDARY_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_BOUNDARY_OPTIONS.echelonPadding, metersPerPixel, labelSizeZoom, labelSizeResolution, labelRepetitions = DEFAULT_BOUNDARY_OPTIONS.labelRepetitions, labelSpacing = DEFAULT_BOUNDARY_OPTIONS.labelSpacing, labelPadding = DEFAULT_BOUNDARY_OPTIONS.labelPadding, labelOrientation = DEFAULT_BOUNDARY_OPTIONS.labelOrientation, smooth = DEFAULT_BOUNDARY_OPTIONS.smooth, smoothResolution = DEFAULT_BOUNDARY_OPTIONS.smoothResolution } = options;
3204
+ const unit1Designator = textAmplifiers.T ?? "";
3205
+ const unit1Country = textAmplifiers.AS ?? "";
3206
+ const unit2Designator = textAmplifiers.T1 ?? "";
3207
+ const unit2Country = textAmplifiers.AS1 ?? "";
3153
3208
  let h = echelonSize;
3154
3209
  if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
3155
3210
  h = Math.max(EPSILON, h);
@@ -3199,32 +3254,50 @@ function createBoundary(positions, options = {}) {
3199
3254
  }
3200
3255
  const d = Math.max(0, Math.min(seg.length, target - acc));
3201
3256
  const center = vecAdd(seg.start, vecScale(seg.along, d));
3202
- const rotation = labelRotationAlong(seg.along);
3257
+ const units = [{
3258
+ side: 1,
3259
+ designator: unit1Designator,
3260
+ country: unit1Country
3261
+ }, {
3262
+ side: -1,
3263
+ designator: unit2Designator,
3264
+ country: unit2Country
3265
+ }];
3266
+ const rotateGroup = labelOrientation === "standard" && Math.abs(seg.along[0]) < Math.abs(seg.along[1]);
3267
+ const readingAxis = seg.perp[0] >= 0 ? seg.perp : vecScale(seg.perp, -1);
3268
+ const glyphAlong = rotateGroup ? readingAxis : seg.along;
3269
+ const glyphPerp = rotateGroup ? [-readingAxis[1], readingAxis[0]] : seg.perp;
3270
+ let glyphWidth = 0;
3203
3271
  if (spec) {
3204
- const { strokes, fills, width } = buildEchelonGlyph(center, seg.along, seg.perp, h, spec);
3272
+ const { strokes, fills, width } = buildEchelonGlyph(center, glyphAlong, glyphPerp, h, spec);
3273
+ glyphWidth = width;
3205
3274
  glyphStrokes.push(...strokes);
3206
3275
  glyphFills.push(...fills);
3207
- const halfGap = width / 2 + Math.max(0, echelonPadding) * h;
3276
+ const halfGap = (rotateGroup ? h : width) / 2 + Math.max(0, echelonPadding) * h;
3208
3277
  gaps.push({
3209
3278
  g0: target - halfGap,
3210
3279
  g1: target + halfGap
3211
3280
  });
3212
3281
  }
3213
- pushUnitLabels(labelFeatures, {
3282
+ if (rotateGroup) {
3283
+ const sign = vecDot(seg.perp, readingAxis) >= 0 ? 1 : -1;
3284
+ const clearance = glyphWidth / 2 + Math.max(0, labelPadding) * h;
3285
+ units[0].textAnchor = sign > 0 ? "start" : "end";
3286
+ units[1].textAnchor = sign > 0 ? "end" : "start";
3287
+ pushUnitLabels(labelFeatures, {
3288
+ center,
3289
+ perp: vecScale(readingAxis, sign),
3290
+ offset: clearance,
3291
+ rotation: labelRotationAlong(readingAxis),
3292
+ labelSize
3293
+ }, units);
3294
+ } else pushUnitLabels(labelFeatures, {
3214
3295
  center,
3215
3296
  perp: seg.perp,
3216
- h,
3217
- rotation,
3297
+ offset: (.45 + Math.max(0, labelPadding)) * h,
3298
+ rotation: labelRotationAlong(seg.along),
3218
3299
  labelSize
3219
- }, [{
3220
- side: 1,
3221
- designator: unit1Designator,
3222
- country: unit1Country
3223
- }, {
3224
- side: -1,
3225
- designator: unit2Designator,
3226
- country: unit2Country
3227
- }]);
3300
+ }, units);
3228
3301
  }
3229
3302
  const boundaryCoords = buildGappedLine(verts, gaps);
3230
3303
  const features = [];
@@ -4990,7 +5063,7 @@ const BLADE_LENGTH_FRACTION = .8;
4990
5063
  const DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS = {
4991
5064
  arrowheadLengthRatio: .07,
4992
5065
  arrowheadWidthRatio: .67,
4993
- bladeHeight: .03
5066
+ bladeHeightRatio: .03
4994
5067
  };
4995
5068
  const FPF_SHARED = {
4996
5069
  entity: "Maneuver Lines",
@@ -5020,7 +5093,7 @@ const FPF_SHARED = {
5020
5093
  step: .01
5021
5094
  },
5022
5095
  {
5023
- key: "bladeHeight",
5096
+ key: "bladeHeightRatio",
5024
5097
  label: "Blade height",
5025
5098
  description: "Blade's perpendicular reach into the V, as a fraction of its arm's length",
5026
5099
  type: "number",
@@ -5050,7 +5123,7 @@ const FINAL_PROTECTIVE_FIRE_RIGHT_METADATA = {
5050
5123
  * Builds the filled "blade": a uniform-width rectangle hugging the inside of
5051
5124
  * one arm. One long edge lies on the central 80% of the arm line (connected to
5052
5125
  * the line); the strip bulges perpendicularly into the inside of the V by
5053
- * `bladeHeight * armLength`.
5126
+ * `bladeHeightRatio * armLength`.
5054
5127
  *
5055
5128
  * @param vertex - PT 1, the shared vertex of the V (projected)
5056
5129
  * @param tip - the outward tip of the bladed arm (projected)
@@ -5058,7 +5131,7 @@ const FINAL_PROTECTIVE_FIRE_RIGHT_METADATA = {
5058
5131
  * be undefined during live single-arm feedback, in which case a default side
5059
5132
  * is chosen
5060
5133
  */
5061
- function bladeRectangle(vertex, tip, otherTip, bladeHeight) {
5134
+ function bladeRectangle(vertex, tip, otherTip, bladeHeightRatio) {
5062
5135
  const v = vecSub(tip, vertex);
5063
5136
  const armLength = vecMag(v);
5064
5137
  if (armLength < 1e-6) return null;
@@ -5071,7 +5144,7 @@ function bladeRectangle(vertex, tip, otherTip, bladeHeight) {
5071
5144
  const gap = (1 - BLADE_LENGTH_FRACTION) / 2;
5072
5145
  const onLineNear = vecAdd(vertex, vecScale(dir, armLength * gap));
5073
5146
  const onLineFar = vecAdd(vertex, vecScale(dir, armLength * (1 - gap)));
5074
- const height = vecScale(perp, armLength * bladeHeight);
5147
+ const height = vecScale(perp, armLength * bladeHeightRatio);
5075
5148
  return [
5076
5149
  onLineNear,
5077
5150
  onLineFar,
@@ -5093,7 +5166,7 @@ function bladeRectangle(vertex, tip, otherTip, bladeHeight) {
5093
5166
  * @param options - Arrowhead and blade sizing parameters
5094
5167
  */
5095
5168
  function createFinalProtectiveFire(coordinates, bladedArm, options = {}) {
5096
- const { arrowheadLengthRatio, arrowheadWidthRatio, bladeHeight } = {
5169
+ const { arrowheadLengthRatio, arrowheadWidthRatio, bladeHeightRatio } = {
5097
5170
  ...DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS,
5098
5171
  ...options
5099
5172
  };
@@ -5113,7 +5186,7 @@ function createFinalProtectiveFire(coordinates, bladedArm, options = {}) {
5113
5186
  const bladeTip = v.tips[bladedArm];
5114
5187
  if (bladeTip) {
5115
5188
  const otherTip = v.tips[bladedArm === 0 ? 1 : 0];
5116
- const blade = bladeRectangle(v.vertex, bladeTip, otherTip, Math.max(0, bladeHeight));
5189
+ const blade = bladeRectangle(v.vertex, bladeTip, otherTip, Math.max(0, bladeHeightRatio));
5117
5190
  if (blade) {
5118
5191
  const ring = [...blade, blade[0]].map((c) => unproject(c[0], c[1]));
5119
5192
  features.push({
@@ -5299,14 +5372,14 @@ const FIX = defineControlMeasure({
5299
5372
  //#region src/generators/cm14-maneuver-lines/flot.ts
5300
5373
  const DEFAULT_RADIUS = 50;
5301
5374
  const DEFAULT_RADIUS_PIXELS = 10;
5302
- const DEFAULT_RESOLUTION = 16;
5375
+ const DEFAULT_ARC_SEGMENTS = 16;
5303
5376
  /**
5304
5377
  * Default options for the FLOT graphic.
5305
5378
  */
5306
5379
  const DEFAULT_FLOT_OPTIONS = {
5307
5380
  radius: DEFAULT_RADIUS,
5308
5381
  radiusPixels: DEFAULT_RADIUS_PIXELS,
5309
- resolution: DEFAULT_RESOLUTION
5382
+ arcSegments: DEFAULT_ARC_SEGMENTS
5310
5383
  };
5311
5384
  const FLOT_METADATA = {
5312
5385
  id: "flot",
@@ -5341,8 +5414,8 @@ const FLOT_METADATA = {
5341
5414
  unit: "m"
5342
5415
  },
5343
5416
  {
5344
- key: "resolution",
5345
- label: "Resolution",
5417
+ key: "arcSegments",
5418
+ label: "Arc segments",
5346
5419
  description: "Number of points used to render each semicircle",
5347
5420
  type: "number",
5348
5421
  min: 4,
@@ -5362,16 +5435,16 @@ const FLOT_METADATA = {
5362
5435
  * @param segmentDir - Direction vector of the segment (normalized)
5363
5436
  * @param perpDir - Perpendicular direction vector (normalized)
5364
5437
  * @param radius - Radius of the semicircle
5365
- * @param resolution - Number of points in the arc
5438
+ * @param arcSegments - Number of points in the arc
5366
5439
  * @param includeStart - Whether to include the start point
5367
5440
  * @returns Array of arc points forming a true semicircle
5368
5441
  */
5369
- function generateArcPoints(startPoint, segmentDir, perpDir, radius, resolution, includeStart) {
5442
+ function generateArcPoints(startPoint, segmentDir, perpDir, radius, arcSegments, includeStart) {
5370
5443
  const points = [];
5371
5444
  const startIndex = includeStart ? 0 : 1;
5372
5445
  const center = vecAdd(startPoint, vecScale(segmentDir, radius));
5373
- for (let i = startIndex; i <= resolution; i++) {
5374
- const t = i / resolution;
5446
+ for (let i = startIndex; i <= arcSegments; i++) {
5447
+ const t = i / arcSegments;
5375
5448
  const angle = Math.PI * (1 - t);
5376
5449
  const alongSegment = Math.cos(angle) * radius;
5377
5450
  const perpOffset = Math.sin(angle) * radius;
@@ -5386,11 +5459,11 @@ function generateArcPoints(startPoint, segmentDir, perpDir, radius, resolution,
5386
5459
  * @param p1 - Start position (projected)
5387
5460
  * @param p2 - End position (projected)
5388
5461
  * @param radius - Radius of each semicircle
5389
- * @param resolution - Points per arc
5462
+ * @param arcSegments - Points per arc
5390
5463
  * @param isFirstSegment - Whether this is the first segment (include start point)
5391
5464
  * @returns Array of points forming the scalloped line for this segment
5392
5465
  */
5393
- function processSegment(p1, p2, radius, resolution, isFirstSegment) {
5466
+ function processSegment(p1, p2, radius, arcSegments, isFirstSegment) {
5394
5467
  const segmentVec = vecSub(p2, p1);
5395
5468
  const segmentLength = vecMag(segmentVec);
5396
5469
  if (segmentLength < 1e-6) return isFirstSegment ? [p1] : [];
@@ -5402,14 +5475,14 @@ function processSegment(p1, p2, radius, resolution, isFirstSegment) {
5402
5475
  const remainingLength = segmentLength - numFullArcs * diameter;
5403
5476
  let currentPos = p1;
5404
5477
  for (let arcIndex = 0; arcIndex < numFullArcs; arcIndex++) {
5405
- const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, radius, resolution, arcIndex === 0 && isFirstSegment);
5478
+ const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, radius, arcSegments, arcIndex === 0 && isFirstSegment);
5406
5479
  points.push(...arcPoints);
5407
5480
  currentPos = vecAdd(currentPos, vecScale(segmentDir, diameter));
5408
5481
  }
5409
5482
  if (remainingLength > diameter * .1) {
5410
5483
  const partialRadius = remainingLength / 2;
5411
- const partialResolution = Math.max(3, Math.round(resolution * (partialRadius / radius)));
5412
- const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, partialRadius, partialResolution, numFullArcs === 0 && isFirstSegment);
5484
+ const partialArcSegments = Math.max(3, Math.round(arcSegments * (partialRadius / radius)));
5485
+ const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, partialRadius, partialArcSegments, numFullArcs === 0 && isFirstSegment);
5413
5486
  points.push(...arcPoints);
5414
5487
  }
5415
5488
  return points;
@@ -5429,22 +5502,22 @@ function processSegment(p1, p2, radius, resolution, isFirstSegment) {
5429
5502
  * ```typescript
5430
5503
  * const flot = createFLOT(
5431
5504
  * [[-122.4194, 37.7749], [-122.4100, 37.7800], [-122.4000, 37.7750]],
5432
- * { radius: 100, resolution: 20 }
5505
+ * { radius: 100, arcSegments: 20 }
5433
5506
  * );
5434
5507
  * ```
5435
5508
  */
5436
5509
  function createFLOT(positions, options = {}) {
5437
- const { radius = DEFAULT_RADIUS, radiusPixels, metersPerPixel, resolution = DEFAULT_RESOLUTION } = options;
5510
+ const { radius = DEFAULT_RADIUS, radiusPixels, metersPerPixel, arcSegments = DEFAULT_ARC_SEGMENTS } = options;
5438
5511
  let effectiveRadius = radius;
5439
5512
  if (radiusPixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveRadius = radiusPixels * metersPerPixel;
5440
5513
  const safeRadius = Math.max(EPSILON, effectiveRadius);
5441
- const safeResolution = Math.max(3, Math.round(resolution));
5514
+ const safeArcSegments = Math.max(3, Math.round(arcSegments));
5442
5515
  const projectedPoints = positions.map((p) => project(p[0], p[1]));
5443
5516
  const allPoints = [];
5444
5517
  for (let i = 0; i < projectedPoints.length - 1; i++) {
5445
5518
  const p1 = projectedPoints[i];
5446
5519
  const p2 = projectedPoints[i + 1];
5447
- const segmentPoints = processSegment(p1, p2, safeRadius, safeResolution, i === 0);
5520
+ const segmentPoints = processSegment(p1, p2, safeRadius, safeArcSegments, i === 0);
5448
5521
  allPoints.push(...segmentPoints);
5449
5522
  }
5450
5523
  if (allPoints.length > 0 && projectedPoints.length > 1) {
@@ -5473,15 +5546,15 @@ const FLOT = defineControlMeasure({
5473
5546
  });
5474
5547
  //#endregion
5475
5548
  //#region src/generators/cm29-protection-lines/fortifiedLine.ts
5476
- const DEFAULT_SIZE = 50;
5477
- const DEFAULT_SIZE_PIXELS = 10;
5549
+ const DEFAULT_TOOTH_SIZE = 50;
5550
+ const DEFAULT_TOOTH_SIZE_PIXELS = 10;
5478
5551
  const DEFAULT_SMOOTH_RESOLUTION$1 = 16;
5479
5552
  /**
5480
5553
  * Default options for the Fortified Line graphic.
5481
5554
  */
5482
5555
  const DEFAULT_FORTIFIED_LINE_OPTIONS = {
5483
- size: DEFAULT_SIZE,
5484
- sizePixels: DEFAULT_SIZE_PIXELS,
5556
+ toothSize: DEFAULT_TOOTH_SIZE,
5557
+ toothSizePixels: DEFAULT_TOOTH_SIZE_PIXELS,
5485
5558
  smooth: false,
5486
5559
  smoothResolution: DEFAULT_SMOOTH_RESOLUTION$1
5487
5560
  };
@@ -5571,9 +5644,9 @@ function processFortifiedSegment(p1, p2, size, isFirstSegment) {
5571
5644
  * Calculates the effective size based on options and projection.
5572
5645
  */
5573
5646
  function calculateEffectiveSize(options) {
5574
- const { size = DEFAULT_SIZE, sizePixels, metersPerPixel } = options;
5575
- let effectiveSize = size;
5576
- if (sizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveSize = sizePixels * metersPerPixel;
5647
+ const { toothSize = DEFAULT_TOOTH_SIZE, toothSizePixels, metersPerPixel } = options;
5648
+ let effectiveSize = toothSize;
5649
+ if (toothSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveSize = toothSizePixels * metersPerPixel;
5577
5650
  return Math.max(EPSILON, effectiveSize);
5578
5651
  }
5579
5652
  /**
@@ -5825,8 +5898,8 @@ const ISOLATE = defineControlMeasure({
5825
5898
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
5826
5899
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
5827
5900
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
5828
- roundedBends: false,
5829
- bendSegments: 5
5901
+ smooth: false,
5902
+ smoothResolution: 5
5830
5903
  };
5831
5904
  const MAIN_ATTACK_METADATA = {
5832
5905
  id: "main-attack",
@@ -6731,8 +6804,8 @@ const STRONG_POINT = defineControlMeasure({
6731
6804
  //#region src/generators/cm15-maneuver-areas/supportByFire.ts
6732
6805
  const DEFAULT_SUPPORT_BY_FIRE_OPTIONS = {
6733
6806
  arrowheadWidthRatio: .3,
6734
- backLineLengthRatio: .3,
6735
- backLineAngle: 45
6807
+ rearLineLengthRatio: .3,
6808
+ rearLineAngle: 45
6736
6809
  };
6737
6810
  const SUPPORT_BY_FIRE_METADATA = {
6738
6811
  id: "support-by-fire",
@@ -6759,8 +6832,8 @@ const SUPPORT_BY_FIRE_METADATA = {
6759
6832
  function createSupportByFire(coordinates, options = {}) {
6760
6833
  const featureProps = {};
6761
6834
  const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.arrowheadWidthRatio;
6762
- const backLineLengthRatio = options.backLineLengthRatio ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.backLineLengthRatio;
6763
- const backLineAngle = options.backLineAngle ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.backLineAngle;
6835
+ const rearLineLengthRatio = options.rearLineLengthRatio ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.rearLineLengthRatio;
6836
+ const rearLineAngle = options.rearLineAngle ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.rearLineAngle;
6764
6837
  const c1 = coordinates[0];
6765
6838
  const c2 = coordinates[1];
6766
6839
  const c3 = coordinates[2];
@@ -6776,13 +6849,13 @@ function createSupportByFire(coordinates, options = {}) {
6776
6849
  type: "FeatureCollection",
6777
6850
  features: []
6778
6851
  };
6779
- const angleRad = backLineAngle * Math.PI / 180;
6852
+ const angleRad = rearLineAngle * Math.PI / 180;
6780
6853
  const baseAngle = -Math.PI / 2;
6781
6854
  const angleLeft = baseAngle - angleRad;
6782
6855
  const dirBackLeft = [dirBase[0] * Math.cos(angleLeft) - dirBase[1] * Math.sin(angleLeft), dirBase[0] * Math.sin(angleLeft) + dirBase[1] * Math.cos(angleLeft)];
6783
6856
  const angleRight = baseAngle + angleRad;
6784
6857
  const dirBackRight = [dirBase[0] * Math.cos(angleRight) - dirBase[1] * Math.sin(angleRight), dirBase[0] * Math.sin(angleRight) + dirBase[1] * Math.cos(angleRight)];
6785
- const backLen = lenBase * backLineLengthRatio;
6858
+ const backLen = lenBase * rearLineLengthRatio;
6786
6859
  const p1Back = vecAdd(p1, vecScale(dirBackLeft, backLen));
6787
6860
  const p2Back = vecAdd(p2, vecScale(dirBackRight, backLen));
6788
6861
  const vLeft = vecSub(p3, p1);
@@ -6877,8 +6950,8 @@ const SUPPORT_BY_FIRE = defineControlMeasure({
6877
6950
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
6878
6951
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
6879
6952
  shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6880
- roundedBends: false,
6881
- bendSegments: 5
6953
+ smooth: false,
6954
+ smoothResolution: 5
6882
6955
  };
6883
6956
  const SUPPORTING_ATTACK_METADATA = {
6884
6957
  id: "supporting-attack",
@@ -7208,6 +7281,127 @@ function applyLayer(target, layer) {
7208
7281
  }
7209
7282
  }
7210
7283
  //#endregion
7284
+ //#region src/text-amplifiers.ts
7285
+ /**
7286
+ * The vocabulary of text amplifier fields: doctrinal code, milsymbol-style
7287
+ * friendly alias, and a human-readable label. The single source of truth for
7288
+ * both the runtime catalog and the derived alias→code lookup used by
7289
+ * {@link normalizeTextAmplifiers}.
7290
+ */
7291
+ const TEXT_AMPLIFIER_FIELDS = {
7292
+ C: {
7293
+ alias: "quantity",
7294
+ label: "Quantity"
7295
+ },
7296
+ H: {
7297
+ alias: "additionalInformation",
7298
+ label: "Additional information"
7299
+ },
7300
+ N: {
7301
+ alias: "hostile",
7302
+ label: "Hostile (ENY) marker"
7303
+ },
7304
+ T: {
7305
+ alias: "uniqueDesignation",
7306
+ label: "Unique designation"
7307
+ },
7308
+ V: {
7309
+ alias: "type",
7310
+ label: "Equipment/type"
7311
+ },
7312
+ W: {
7313
+ alias: "dtg",
7314
+ label: "Date-time group / O/O"
7315
+ },
7316
+ X: {
7317
+ alias: "altitudeDepth",
7318
+ label: "Altitude/depth"
7319
+ },
7320
+ Y: {
7321
+ alias: "location",
7322
+ label: "Location"
7323
+ },
7324
+ AM: {
7325
+ alias: "distance",
7326
+ label: "Distance/range/radius"
7327
+ },
7328
+ AN: {
7329
+ alias: "azimuth",
7330
+ label: "Azimuth"
7331
+ },
7332
+ AP: {
7333
+ alias: "targetNumber",
7334
+ label: "Target number"
7335
+ },
7336
+ AS: {
7337
+ alias: "country",
7338
+ label: "Country code"
7339
+ }
7340
+ };
7341
+ /** Derived alias→code lookup, so the vocabulary table stays the single source of truth. */
7342
+ const CODE_BY_ALIAS = Object.fromEntries(Object.entries(TEXT_AMPLIFIER_FIELDS).map(([code, { alias }]) => [alias, code]));
7343
+ const CANONICAL_KEYS = new Set(Object.keys(TEXT_AMPLIFIER_FIELDS).flatMap((code) => [
7344
+ code,
7345
+ `${code}1`,
7346
+ `${code}2`
7347
+ ]));
7348
+ const EMPTY_TEXT_AMPLIFIERS = Object.freeze({});
7349
+ /** Splits a key like `"country1"` into its base (`"country"`) and suffix (`"1"`). */
7350
+ function splitSuffix(key) {
7351
+ const last = key.charAt(key.length - 1);
7352
+ if (last === "1" || last === "2") return {
7353
+ base: key.slice(0, -1),
7354
+ suffix: last
7355
+ };
7356
+ return {
7357
+ base: key,
7358
+ suffix: ""
7359
+ };
7360
+ }
7361
+ /**
7362
+ * Resolves any accepted key (canonical or friendly alias, with an optional
7363
+ * `1`/`2` suffix) to its canonical key, or `undefined` if unknown — e.g.
7364
+ * `"T"` → `"T"`, `"uniqueDesignation1"` → `"T1"`, `"bogus"` → `undefined`.
7365
+ */
7366
+ function canonicalTextAmplifierKey(key) {
7367
+ if (CANONICAL_KEYS.has(key)) return key;
7368
+ const { base, suffix } = splitSuffix(key);
7369
+ const code = CODE_BY_ALIAS[base];
7370
+ return code ? `${code}${suffix}` : void 0;
7371
+ }
7372
+ /**
7373
+ * Resolves a `TextAmplifiers` record (canonical keys, friendly aliases, or a
7374
+ * mix) to `CanonicalTextAmplifiers` keyed purely by doctrinal field code.
7375
+ *
7376
+ * - Friendly aliases (with an optional `1`/`2` suffix) map to their canonical
7377
+ * code (e.g. `country1` → `AS1`).
7378
+ * - Values are trimmed; entries that are empty/whitespace-only after
7379
+ * trimming, or not strings, are dropped.
7380
+ * - When both a canonical key and its friendly alias are present for the same
7381
+ * field + suffix, the **canonical key wins** regardless of insertion order.
7382
+ * This holds even for an empty/whitespace canonical value: it suppresses
7383
+ * (clears) the alias entry rather than letting the alias survive.
7384
+ * - Unknown keys are ignored.
7385
+ *
7386
+ * `undefined` or an empty input returns a shared frozen empty object.
7387
+ */
7388
+ function normalizeTextAmplifiers(input) {
7389
+ if (!input) return EMPTY_TEXT_AMPLIFIERS;
7390
+ const canonicalPresent = /* @__PURE__ */ new Set();
7391
+ for (const [key, rawValue] of Object.entries(input)) if (typeof rawValue === "string" && CANONICAL_KEYS.has(key)) canonicalPresent.add(key);
7392
+ const result = {};
7393
+ for (const [key, rawValue] of Object.entries(input)) {
7394
+ if (typeof rawValue !== "string") continue;
7395
+ const value = rawValue.trim();
7396
+ if (value.length === 0) continue;
7397
+ const canonicalKey = canonicalTextAmplifierKey(key);
7398
+ if (!canonicalKey) continue;
7399
+ if (!CANONICAL_KEYS.has(key) && canonicalPresent.has(canonicalKey)) continue;
7400
+ result[canonicalKey] = value;
7401
+ }
7402
+ return Object.keys(result).length > 0 ? Object.freeze(result) : EMPTY_TEXT_AMPLIFIERS;
7403
+ }
7404
+ //#endregion
7211
7405
  //#region src/renderControlMeasure.ts
7212
7406
  /**
7213
7407
  * Pure render: `ControlMeasure` → `ControlMeasureRender` (a
@@ -7233,7 +7427,8 @@ function dispatchControlMeasure(cm, opts) {
7233
7427
  const definition = DEFINITIONS[cm.kind];
7234
7428
  if (!validateInputContract(cm.controlPoints, definition.metadata, opts?.validationMode)) return EMPTY_COLLECTION;
7235
7429
  const generator = definition.generator;
7236
- return generator(cm.controlPoints, cm.options ?? {});
7430
+ const textAmplifiers = normalizeTextAmplifiers(cm.textAmplifiers);
7431
+ return generator(cm.controlPoints, cm.options ?? {}, textAmplifiers);
7237
7432
  }
7238
7433
  /**
7239
7434
  * The control-point *input contract*: at least `metadata.minCoordinates` points,
@@ -7259,6 +7454,7 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
7259
7454
  const textSizePixels = typeof sourceProps.textSizePixels === "number" ? sourceProps.textSizePixels : void 0;
7260
7455
  const textSizeZoom = typeof sourceProps.textSizeZoom === "number" ? sourceProps.textSizeZoom : void 0;
7261
7456
  const textSizeResolution = typeof sourceProps.textSizeResolution === "number" ? sourceProps.textSizeResolution : void 0;
7457
+ const textAnchor = sourceProps.textAnchor === "start" || sourceProps.textAnchor === "end" ? sourceProps.textAnchor : void 0;
7262
7458
  return {
7263
7459
  type: "Feature",
7264
7460
  id: `${id}:${part}:${index}`,
@@ -7268,6 +7464,7 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
7268
7464
  style,
7269
7465
  ...text !== void 0 ? { text } : {},
7270
7466
  ...rotation !== void 0 ? { rotation } : {},
7467
+ ...textAnchor !== void 0 ? { textAnchor } : {},
7271
7468
  ...textSizePixels !== void 0 ? { textSizePixels } : {},
7272
7469
  ...textSizeZoom !== void 0 ? { textSizeZoom } : {},
7273
7470
  ...textSizeResolution !== void 0 ? { textSizeResolution } : {}
@@ -7321,4 +7518,4 @@ function assertNever(value) {
7321
7518
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
7322
7519
  }
7323
7520
  //#endregion
7324
- export { point12DrawRule as $, DEFAULT_GENERIC_POLYGON_OPTIONS as A, DEFAULT_ANTITANK_WALL_OPTIONS as B, DEFAULT_FIX_OPTIONS as C, DEFAULT_DELAY_OPTIONS as D, DEFAULT_DISRUPT_OPTIONS as E, DEFAULT_BREACH_OPTIONS as F, axis1DrawRule as G, DEFAULT_AMBUSH_OPTIONS as H, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as I, line23DrawRule as J, supportByFireDrawRule as K, DEFAULT_BLOCK_OPTIONS as L, DEFAULT_GENERIC_CIRCLE_OPTIONS as M, DEFAULT_CANALIZE_OPTIONS as N, DEFAULT_CLEAR_OPTIONS as O, DEFAULT_BYPASS_OPTIONS as P, attackByFireDrawRule as Q, DEFAULT_ATTACK_HELICOPTER_OPTIONS as R, DEFAULT_FLOT_OPTIONS as S, DEFAULT_ENCIRCLEMENT_OPTIONS as T, DEFAULT_AIRBORNE_ATTACK_OPTIONS as U, DEFAULT_ANTITANK_DITCH_OPTIONS as V, rectangleDrawRule as W, line1DrawRule as X, turnDrawRule as Y, ambushDrawRule as Z, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as _, DEFINITIONS as a, getMidpointPerpendicularSignedDistance as at, DEFAULT_FORTIFIED_AREA_OPTIONS as b, getDefaultOptions as c, createBaselineFrame as ct, DEFAULT_SUPPORTING_ATTACK_OPTIONS as d, project as dt, centerRadiusDrawRule as et, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as f, unproject as ft, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as g, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as h, roundToFixed as ht, CONTROL_MEASURE_METADATA as i, createMidpointPerpendicularDrawRule as it, DEFAULT_GENERIC_LINE_OPTIONS as j, DEFAULT_GENERIC_RECTANGLE_OPTIONS as k, listControlMeasureMetadata as l, calculateMetrics as lt, DEFAULT_TACTICAL_ARROW_OPTIONS as m, getMetersPerPixel as mt, resolveStyleHints as n, blockDrawRule as nt, getControlMeasureMetadata as o, pointOnMidpointPerpendicularAxis as ot, DEFAULT_STRONG_POINT_OPTIONS as p, EPSILON as pt, line24DrawRule as q, CONTROL_MEASURE_IDS as r, computeDefaultMidpointPerpendicularPoint as rt, getControlMeasureMetadataByValue as s, snapToMidpointPerpendicular as st, renderControlMeasure as t, disruptDrawRule as tt, DEFAULT_TURN_OPTIONS as u, computeInitialWidthPoint as ut, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as v, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as w, DEFAULT_FORTIFIED_LINE_OPTIONS as x, DEFAULT_MAIN_ATTACK_OPTIONS as y, DEFAULT_ATTACK_BY_FIRE_OPTIONS as z };
7521
+ export { supportByFireDrawRule as $, DEFAULT_DELAY_OPTIONS as A, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as B, DEFAULT_FORTIFIED_AREA_OPTIONS as C, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as D, DEFAULT_FIX_OPTIONS as E, DEFAULT_GENERIC_CIRCLE_OPTIONS as F, DEFAULT_ATTACK_HELICOPTER_OPTIONS as G, DEFAULT_BLOCK_ARROW_OPTIONS as H, DEFAULT_CLASSIC_ARROW_OPTIONS as I, DEFAULT_ANTITANK_DITCH_OPTIONS as J, DEFAULT_ATTACK_BY_FIRE_OPTIONS as K, DEFAULT_CANALIZE_OPTIONS as L, DEFAULT_GENERIC_RECTANGLE_OPTIONS as M, DEFAULT_GENERIC_POLYGON_OPTIONS as N, DEFAULT_ENCIRCLEMENT_OPTIONS as O, DEFAULT_GENERIC_LINE_OPTIONS as P, axis1DrawRule as Q, DEFAULT_BYPASS_OPTIONS as R, DEFAULT_MAIN_ATTACK_OPTIONS as S, roundToFixed as St, DEFAULT_FLOT_OPTIONS as T, DEFAULT_BLOCK_OPTIONS as U, DEFAULT_BOUNDARY_OPTIONS as V, DEFAULT_BATTLE_POSITION_OPTIONS as W, DEFAULT_AIRBORNE_ATTACK_OPTIONS as X, DEFAULT_AMBUSH_OPTIONS as Y, rectangleDrawRule as Z, DEFAULT_TACTICAL_ARROW_OPTIONS as _, computeInitialWidthPoint as _t, resolveStyleHints as a, attackByFireDrawRule as at, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as b, EPSILON as bt, DEFINITIONS as c, disruptDrawRule as ct, getDefaultOptions as d, createMidpointPerpendicularDrawRule as dt, line24DrawRule as et, listControlMeasureMetadata as f, getMidpointPerpendicularSignedDistance as ft, DEFAULT_STRONG_POINT_OPTIONS as g, calculateMetrics as gt, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as h, createBaselineFrame as ht, normalizeTextAmplifiers as i, ambushDrawRule as it, DEFAULT_CLEAR_OPTIONS as j, DEFAULT_DISRUPT_OPTIONS as k, getControlMeasureMetadata as l, blockDrawRule as lt, DEFAULT_SUPPORTING_ATTACK_OPTIONS as m, snapToMidpointPerpendicular as mt, TEXT_AMPLIFIER_FIELDS as n, turnDrawRule as nt, CONTROL_MEASURE_IDS as o, point12DrawRule as ot, DEFAULT_TURN_OPTIONS as p, pointOnMidpointPerpendicularAxis as pt, DEFAULT_ANTITANK_WALL_OPTIONS as q, canonicalTextAmplifierKey as r, line1DrawRule as rt, CONTROL_MEASURE_METADATA as s, centerRadiusDrawRule as st, renderControlMeasure as t, line23DrawRule as tt, getControlMeasureMetadataByValue as u, computeDefaultMidpointPerpendicularPoint as ut, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as v, project as vt, DEFAULT_FORTIFIED_LINE_OPTIONS as w, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as x, getMetersPerPixel as xt, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as y, unproject as yt, DEFAULT_BREACH_OPTIONS as z };