@orbat-mapper/control-measures 0.2.0-alpha.16 → 0.2.0-alpha.18

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.
@@ -273,6 +273,7 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoo
273
273
  ];
274
274
  const fullSpine = [...remainingSpine, shaftEndCenter];
275
275
  return {
276
+ shaftCenterline: fullSpine,
276
277
  shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, smooth, smoothResolution),
277
278
  shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, smooth, smoothResolution),
278
279
  headRing,
@@ -1411,6 +1412,13 @@ const AREA_DESIGNATION_AMPLIFIER = {
1411
1412
  description: "Field T — the unit's designation, centered in the area.",
1412
1413
  maxLength: 21
1413
1414
  };
1415
+ const AREA_HOSTILE_AMPLIFIER = {
1416
+ key: "N",
1417
+ label: "Hostile (ENY) marker",
1418
+ description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
1419
+ placeholder: "ENY",
1420
+ maxLength: 3
1421
+ };
1414
1422
  const AREA_TEXT_AMPLIFIERS = [
1415
1423
  {
1416
1424
  key: "T",
@@ -1437,17 +1445,9 @@ const AREA_TEXT_AMPLIFIERS = [
1437
1445
  description: "Field W1 — date-time group the area ceases to be effective.",
1438
1446
  maxLength: 16
1439
1447
  },
1440
- {
1441
- key: "N",
1442
- label: "Hostile (ENY) marker",
1443
- description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
1444
- placeholder: "ENY",
1445
- maxLength: 3
1446
- }
1448
+ AREA_HOSTILE_AMPLIFIER
1447
1449
  ];
1448
- /** {@link AREA_TEXT_AMPLIFIERS} without the N (hostile) field, for measures
1449
- * whose template carries no ENY marker (Fortified Area). */
1450
- const AREA_TEXT_AMPLIFIERS_NO_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "N");
1450
+ const AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS = [AREA_DESIGNATION_AMPLIFIER, AREA_HOSTILE_AMPLIFIER];
1451
1451
  /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
1452
1452
  * for measures whose template omits that row but keeps the W/W1 window and
1453
1453
  * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
@@ -2040,6 +2040,10 @@ function polylineSegments(verts) {
2040
2040
  totalLength
2041
2041
  };
2042
2042
  }
2043
+ /** Resolves an along-line position onto the valid start-to-end range. */
2044
+ function clampLinePosition(value, fallback) {
2045
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2046
+ }
2043
2047
  /**
2044
2048
  * Resolves the point at `fraction` (clamped to `[0, 1]`) of `totalLength`
2045
2049
  * along `segments`, walking cumulative arc length exactly like Boundary's
@@ -2135,7 +2139,7 @@ function labelSizeProps(options) {
2135
2139
  return {};
2136
2140
  }
2137
2141
  /** Pushes a `part: "label"` Point feature at projected `point`. */
2138
- function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField) {
2142
+ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField, labelPlacementKey) {
2139
2143
  out.push({
2140
2144
  type: "Feature",
2141
2145
  properties: {
@@ -2144,6 +2148,7 @@ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierF
2144
2148
  rotation,
2145
2149
  ...textAnchor ? { textAnchor } : {},
2146
2150
  ...amplifierField ? { amplifierField } : {},
2151
+ ...labelPlacementKey ? { labelPlacementKey } : {},
2147
2152
  ...sizeProps
2148
2153
  },
2149
2154
  geometry: {
@@ -2297,10 +2302,6 @@ const LABEL_GAP_CLEARANCE_RATIO = .3;
2297
2302
  function nonNegative$1(value, fallback) {
2298
2303
  return Number.isFinite(value) ? Math.max(0, value) : fallback;
2299
2304
  }
2300
- /** Resolves an along-line position option onto the valid tail-to-tip range. */
2301
- function clampLinePosition(value, fallback) {
2302
- return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2303
- }
2304
2305
  /** Arc-length knockout centered on an along-route text label. */
2305
2306
  function directionLabelGap(totalLength, fraction, text, options) {
2306
2307
  if (!text) return void 0;
@@ -2788,6 +2789,141 @@ const DIRECTION_OF_SUPPORTING_ATTACK = defineControlMeasure({
2788
2789
  }
2789
2790
  });
2790
2791
  //#endregion
2792
+ //#region src/text-amplifiers.ts
2793
+ /**
2794
+ * The vocabulary of text amplifier fields: doctrinal code, milsymbol-style
2795
+ * friendly alias, and a human-readable label. The single source of truth for
2796
+ * both the runtime catalog and the derived alias→code lookup used by
2797
+ * {@link normalizeTextAmplifiers}.
2798
+ */
2799
+ const TEXT_AMPLIFIER_FIELDS = {
2800
+ C: {
2801
+ alias: "quantity",
2802
+ label: "Quantity"
2803
+ },
2804
+ H: {
2805
+ alias: "additionalInformation",
2806
+ label: "Additional information"
2807
+ },
2808
+ N: {
2809
+ alias: "hostile",
2810
+ label: "Hostile (ENY) marker"
2811
+ },
2812
+ T: {
2813
+ alias: "uniqueDesignation",
2814
+ label: "Unique designation"
2815
+ },
2816
+ V: {
2817
+ alias: "type",
2818
+ label: "Equipment/type"
2819
+ },
2820
+ W: {
2821
+ alias: "dtg",
2822
+ label: "Date-time group / O/O"
2823
+ },
2824
+ X: {
2825
+ alias: "altitudeDepth",
2826
+ label: "Altitude/depth"
2827
+ },
2828
+ Y: {
2829
+ alias: "location",
2830
+ label: "Location"
2831
+ },
2832
+ AM: {
2833
+ alias: "distance",
2834
+ label: "Distance/range/radius"
2835
+ },
2836
+ AN: {
2837
+ alias: "azimuth",
2838
+ label: "Azimuth"
2839
+ },
2840
+ AP: {
2841
+ alias: "targetNumber",
2842
+ label: "Target number"
2843
+ },
2844
+ AS: {
2845
+ alias: "country",
2846
+ label: "Country code"
2847
+ }
2848
+ };
2849
+ /** Derived alias→code lookup, so the vocabulary table stays the single source of truth. */
2850
+ const CODE_BY_ALIAS = Object.fromEntries(Object.entries(TEXT_AMPLIFIER_FIELDS).map(([code, { alias }]) => [alias, code]));
2851
+ const CANONICAL_KEYS = new Set(Object.keys(TEXT_AMPLIFIER_FIELDS).flatMap((code) => [
2852
+ code,
2853
+ `${code}1`,
2854
+ `${code}2`
2855
+ ]));
2856
+ /**
2857
+ * Resolves a raw placement entry to its object form: a bare `Position` is
2858
+ * legacy shorthand for `{ position }`; an object form passes through only the
2859
+ * fields that are actually valid (`position` must be a finite `[lon, lat]`,
2860
+ * `rotation` a finite number); `undefined` resolves to `{}`.
2861
+ */
2862
+ function resolveAmplifierPlacement(value) {
2863
+ if (value === void 0) return {};
2864
+ if (Array.isArray(value)) return isValidPosition(value) ? { position: value } : {};
2865
+ const result = {};
2866
+ if (value.position !== void 0 && isValidPosition(value.position)) result.position = value.position;
2867
+ if (typeof value.rotation === "number" && Number.isFinite(value.rotation)) result.rotation = value.rotation;
2868
+ return result;
2869
+ }
2870
+ const EMPTY_TEXT_AMPLIFIERS = Object.freeze({});
2871
+ /** Splits a key like `"country1"` into its base (`"country"`) and suffix (`"1"`). */
2872
+ function splitSuffix(key) {
2873
+ const last = key.charAt(key.length - 1);
2874
+ if (last === "1" || last === "2") return {
2875
+ base: key.slice(0, -1),
2876
+ suffix: last
2877
+ };
2878
+ return {
2879
+ base: key,
2880
+ suffix: ""
2881
+ };
2882
+ }
2883
+ /**
2884
+ * Resolves any accepted key (canonical or friendly alias, with an optional
2885
+ * `1`/`2` suffix) to its canonical key, or `undefined` if unknown — e.g.
2886
+ * `"T"` → `"T"`, `"uniqueDesignation1"` → `"T1"`, `"bogus"` → `undefined`.
2887
+ */
2888
+ function canonicalTextAmplifierKey(key) {
2889
+ if (CANONICAL_KEYS.has(key)) return key;
2890
+ const { base, suffix } = splitSuffix(key);
2891
+ const code = CODE_BY_ALIAS[base];
2892
+ return code ? `${code}${suffix}` : void 0;
2893
+ }
2894
+ /**
2895
+ * Resolves a `TextAmplifiers` record (canonical keys, friendly aliases, or a
2896
+ * mix) to `CanonicalTextAmplifiers` keyed purely by doctrinal field code.
2897
+ *
2898
+ * - Friendly aliases (with an optional `1`/`2` suffix) map to their canonical
2899
+ * code (e.g. `country1` → `AS1`).
2900
+ * - Values are trimmed; entries that are empty/whitespace-only after
2901
+ * trimming, or not strings, are dropped.
2902
+ * - When both a canonical key and its friendly alias are present for the same
2903
+ * field + suffix, the **canonical key wins** regardless of insertion order.
2904
+ * This holds even for an empty/whitespace canonical value: it suppresses
2905
+ * (clears) the alias entry rather than letting the alias survive.
2906
+ * - Unknown keys are ignored.
2907
+ *
2908
+ * `undefined` or an empty input returns a shared frozen empty object.
2909
+ */
2910
+ function normalizeTextAmplifiers(input) {
2911
+ if (!input) return EMPTY_TEXT_AMPLIFIERS;
2912
+ const canonicalPresent = /* @__PURE__ */ new Set();
2913
+ for (const [key, rawValue] of Object.entries(input)) if (typeof rawValue === "string" && CANONICAL_KEYS.has(key)) canonicalPresent.add(key);
2914
+ const result = {};
2915
+ for (const [key, rawValue] of Object.entries(input)) {
2916
+ if (typeof rawValue !== "string") continue;
2917
+ const value = rawValue.trim();
2918
+ if (value.length === 0) continue;
2919
+ const canonicalKey = canonicalTextAmplifierKey(key);
2920
+ if (!canonicalKey) continue;
2921
+ if (!CANONICAL_KEYS.has(key) && canonicalPresent.has(canonicalKey)) continue;
2922
+ result[canonicalKey] = value;
2923
+ }
2924
+ return Object.keys(result).length > 0 ? Object.freeze(result) : EMPTY_TEXT_AMPLIFIERS;
2925
+ }
2926
+ //#endregion
2791
2927
  //#region src/internal/area-echelon.ts
2792
2928
  /**
2793
2929
  * Twice the ring's signed area (shoelace). Positive when the ring winds
@@ -3055,6 +3191,36 @@ function ringCrossingsAtY(verts, target) {
3055
3191
  totalArc: dist
3056
3192
  };
3057
3193
  }
3194
+ /** Nearest contact point on a closed ring for a dragged marker anchor. */
3195
+ function nearestPointOnRing(verts, target) {
3196
+ let nearest;
3197
+ let totalArc = 0;
3198
+ for (let i = 0; i < verts.length - 1; i++) {
3199
+ const a = verts[i];
3200
+ const b = verts[i + 1];
3201
+ const seg = vecSub(b, a);
3202
+ const length = vecMag(seg);
3203
+ if (length < 1e-6) continue;
3204
+ const along = vecScale(seg, 1 / length);
3205
+ const t = Math.max(0, Math.min(length, vecDot(vecSub(target, a), along)));
3206
+ const distance = vecMag(vecSub(target, vecAdd(a, vecScale(along, t))));
3207
+ if (!nearest || distance < nearest.distance) nearest = {
3208
+ along,
3209
+ arc: totalArc + t,
3210
+ distance
3211
+ };
3212
+ totalArc += length;
3213
+ }
3214
+ return nearest ? {
3215
+ ...nearest,
3216
+ totalArc
3217
+ } : void 0;
3218
+ }
3219
+ function labelHalfExtent(direction, textWidth, textHeight, rotation) {
3220
+ const widthAxis = [Math.cos(rotation), Math.sin(rotation)];
3221
+ const heightAxis = [-widthAxis[1], widthAxis[0]];
3222
+ return .5 * (textWidth * Math.abs(vecDot(direction, widthAxis)) + textHeight * Math.abs(vecDot(direction, heightAxis)));
3223
+ }
3058
3224
  /**
3059
3225
  * Pushes an area's L-15 text amplifier labels — centered name/H/W-W1 rows and
3060
3226
  * west/east "ENY" edge markers — as `part: "label"` Point features (via
@@ -3073,28 +3239,36 @@ function ringCrossingsAtY(verts, target) {
3073
3239
  *
3074
3240
  * When `texts.hostile` is set, the ring's west/east crossings of the
3075
3241
  * horizontal line through the centroid are found ({@link ringCrossingsAtY});
3076
- * the minimum/maximum-x crossings each get an "ENY" label centered exactly on
3077
- * the crossing point (no inset, no `textAnchor`), at the centroid's latitude.
3078
- * Each marker also carves a masking {@link ArcGap} from the boundary at that
3079
- * crossing's arc length, sized as the support function of the label's
3080
- * axis-aligned bounding box along the crossing edge's own direction `d` (a
3081
- * boundary running vertically there needs roughly the label's height; one
3082
- * running horizontally needs roughly its width):
3242
+ * the minimum/maximum-x crossings are the default anchors for the two "ENY"
3243
+ * labels. A persisted `label:N:west` / `label:N:east` placement replaces that
3244
+ * anchor. Each marker carves a masking {@link ArcGap} at the nearest point on
3245
+ * the boundary only while its estimated text box touches the boundary; moving
3246
+ * it away restores the line. The gap follows the label around any edge and is
3247
+ * sized as the support function of its possibly rotated text box along the
3248
+ * contacted edge's own direction `d` (a boundary running vertically there
3249
+ * needs roughly the label's height; one running horizontally needs roughly
3250
+ * its width):
3083
3251
  * `halfExtent = 0.5 * (textWidth·|d.x| + textHeight·|d.y|) + clearance`, where
3084
3252
  * `textHeight = resolveLabelOffsetMeters(options, 1)`,
3085
3253
  * `textWidth = text.length * ENY_CHAR_ASPECT_RATIO * textHeight`, and
3086
3254
  * `clearance = (ENY_CLEARANCE_RATIO + labelPadding) * textHeight` — mirroring
3087
3255
  * {@link placeEchelon}'s glyph-width masking gap (ADR-0023). Fewer than two
3088
3256
  * crossings (e.g. a degenerate ring, or a concave shape whose centroid
3089
- * latitude misses the boundary) skips the marker (and its gap) silently
3090
- * rather than guessing a placement.
3257
+ * latitude misses the boundary) skips the markers silently rather than
3258
+ * guessing their default placements.
3091
3259
  *
3092
3260
  * A ring with fewer than 2 vertices is degenerate — nothing is placed.
3261
+ *
3262
+ * `maskBoundary` may supply a different rendered boundary to mask while
3263
+ * keeping label placement anchored to `ringVerts`. This is used by Fortified
3264
+ * Area, whose labels are centered against its smooth pre-teeth ring while the
3265
+ * visible boundary follows a longer castellated trace.
3093
3266
  */
3094
- function pushAreaLabels(out, ringVerts, texts, options = {}) {
3267
+ function pushAreaLabels(out, ringVerts, texts, options = {}, amplifierPlacements, maskBoundary) {
3095
3268
  if (ringVerts.length < 2) return [];
3096
3269
  const sizeProps = labelSizeProps(options);
3097
3270
  const padding = Math.max(0, options.labelPadding ?? 0);
3271
+ const maskRingVerts = maskBoundary?.ringVerts ?? ringVerts;
3098
3272
  const [cx, cy] = ringCentroid(ringVerts);
3099
3273
  const rows = [
3100
3274
  [texts.name, "T"],
@@ -3116,15 +3290,27 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
3116
3290
  const { crossings, totalArc } = ringCrossingsAtY(ringVerts, cy);
3117
3291
  if (crossings.length >= 2) {
3118
3292
  const byX = [...crossings].sort((a, b) => a.x - b.x);
3119
- const west = byX[0];
3120
- const east = byX[byX.length - 1];
3121
3293
  const textHeight = resolveLabelOffsetMeters(options, 1);
3122
3294
  const textWidth = texts.hostile.length * ENY_CHAR_ASPECT_RATIO * textHeight;
3123
- const clearance = (ENY_CLEARANCE_RATIO + padding) * textHeight;
3124
- for (const crossing of [west, east]) {
3125
- pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N");
3126
- const halfExtent = .5 * (textWidth * Math.abs(crossing.along[0]) + textHeight * Math.abs(crossing.along[1])) + clearance;
3127
- pushWrappedGap(gaps, crossing.arc - halfExtent, crossing.arc + halfExtent, totalArc);
3295
+ const clearance = (ENY_CLEARANCE_RATIO + padding) * textHeight + Math.max(0, maskBoundary?.extraClearanceMeters ?? 0);
3296
+ for (const [side, crossing] of [["west", byX[0]], ["east", byX[byX.length - 1]]]) {
3297
+ const labelPlacementKey = `label:N:${side}`;
3298
+ const placement = resolveAmplifierPlacement(amplifierPlacements?.[labelPlacementKey]);
3299
+ const rotation = placement.rotation ?? HORIZONTAL_LABEL_ROTATION;
3300
+ const labelPoint = placement.position ? project(placement.position[0], placement.position[1]) : [crossing.x, cy];
3301
+ pushLabel(out, labelPoint, texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N", labelPlacementKey);
3302
+ const usesDifferentMaskRing = maskRingVerts !== ringVerts;
3303
+ const contact = placement.position || usesDifferentMaskRing ? nearestPointOnRing(maskRingVerts, labelPoint) : {
3304
+ along: crossing.along,
3305
+ arc: crossing.arc,
3306
+ distance: 0,
3307
+ totalArc
3308
+ };
3309
+ if (!contact) continue;
3310
+ const normal = [-contact.along[1], contact.along[0]];
3311
+ if (placement.position && contact.distance > labelHalfExtent(normal, textWidth, textHeight, rotation)) continue;
3312
+ const tangentExtent = labelHalfExtent(contact.along, textWidth, textHeight, rotation) + clearance;
3313
+ pushWrappedGap(gaps, contact.arc - tangentExtent, contact.arc + tangentExtent, contact.totalArc);
3128
3314
  }
3129
3315
  }
3130
3316
  }
@@ -3165,11 +3351,11 @@ function boundaryFeature(verts, gaps) {
3165
3351
  * echelon-masked boundary, ADR-0023) instead of a closed `Polygon` — the
3166
3352
  * common (no `N`) case is unaffected.
3167
3353
  */
3168
- function labeledAreaFeatures(positions, options, texts) {
3354
+ function labeledAreaFeatures(positions, options, texts, amplifierPlacements) {
3169
3355
  const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$9, ...labelOptions } = options;
3170
3356
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$9);
3171
3357
  const labelFeatures = [];
3172
- const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions))];
3358
+ const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements))];
3173
3359
  features.push(...labelFeatures);
3174
3360
  return {
3175
3361
  type: "FeatureCollection",
@@ -3207,8 +3393,8 @@ const AREA_METADATA = {
3207
3393
  * at its center with an un-prefixed `T` (Field T) plus an `N` (Field N) ENY
3208
3394
  * marker, per {@link labeledAreaFeatures}.
3209
3395
  */
3210
- function createArea(positions, options = {}, textAmplifiers = {}) {
3211
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""));
3396
+ function createArea(positions, options = {}, textAmplifiers = {}, context = {}) {
3397
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""), context.amplifierPlacements);
3212
3398
  }
3213
3399
  const AREA = defineControlMeasure({
3214
3400
  metadata: AREA_METADATA,
@@ -3454,8 +3640,8 @@ const AREA_OF_OPERATIONS_METADATA = {
3454
3640
  * `positions` labeled at its center with "AO "-prefixed `T` (Field T) plus
3455
3641
  * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
3456
3642
  */
3457
- function createAreaOfOperations(positions, options = {}, textAmplifiers = {}) {
3458
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"));
3643
+ function createAreaOfOperations(positions, options = {}, textAmplifiers = {}, context = {}) {
3644
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"), context.amplifierPlacements);
3459
3645
  }
3460
3646
  const AREA_OF_OPERATIONS = defineControlMeasure({
3461
3647
  metadata: AREA_OF_OPERATIONS_METADATA,
@@ -3474,15 +3660,6 @@ const AREA_OF_OPERATIONS = defineControlMeasure({
3474
3660
  }
3475
3661
  });
3476
3662
  //#endregion
3477
- //#region src/generators/cm12-command-control-areas/params.ts
3478
- const SMOOTH_AREA_PARAMS = AREA_SMOOTH_PARAMS;
3479
- const AREA_DESIGNATION_ONLY_AMPLIFIER = {
3480
- key: "T",
3481
- label: "Unique designation",
3482
- description: "Field T — the area's unique designation.",
3483
- maxLength: 21
3484
- };
3485
- //#endregion
3486
3663
  //#region src/generators/cm12-command-control-areas/named-area-of-interest.ts
3487
3664
  /**
3488
3665
  * Default options for the Named area of interest control measure. The
@@ -3502,19 +3679,23 @@ const NAMED_AREA_OF_INTEREST_METADATA = {
3502
3679
  value: "120200",
3503
3680
  minCoordinates: 3,
3504
3681
  geometry: "area",
3505
- geometryTypes: ["Polygon", "Point"],
3682
+ geometryTypes: [
3683
+ "Polygon",
3684
+ "MultiLineString",
3685
+ "Point"
3686
+ ],
3506
3687
  drawRule: "Area1",
3507
3688
  capturesLabelSize: true,
3508
- params: SMOOTH_AREA_PARAMS,
3509
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
3689
+ params: LABELED_AREA_PARAMS,
3690
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
3510
3691
  };
3511
3692
  /**
3512
3693
  * Creates a Named area of interest control measure: a closed area through
3513
- * `positions` labeled at its center with "NAI "-prefixed `T` (Field T) —
3514
- * the sole label row, per {@link labeledAreaFeatures}.
3694
+ * `positions` labeled at its center with "NAI "-prefixed `T` (Field T) plus
3695
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
3515
3696
  */
3516
- function createNamedAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
3517
- return labeledAreaFeatures(positions, options, { name: composeAreaName("NAI", textAmplifiers.T) });
3697
+ function createNamedAreaOfInterest(positions, options = {}, textAmplifiers = {}, context = {}) {
3698
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "NAI"), context.amplifierPlacements);
3518
3699
  }
3519
3700
  const NAMED_AREA_OF_INTEREST = defineControlMeasure({
3520
3701
  metadata: NAMED_AREA_OF_INTEREST_METADATA,
@@ -3552,19 +3733,23 @@ const TARGET_AREA_OF_INTEREST_METADATA = {
3552
3733
  value: "120300",
3553
3734
  minCoordinates: 3,
3554
3735
  geometry: "area",
3555
- geometryTypes: ["Polygon", "Point"],
3736
+ geometryTypes: [
3737
+ "Polygon",
3738
+ "MultiLineString",
3739
+ "Point"
3740
+ ],
3556
3741
  drawRule: "Area1",
3557
3742
  capturesLabelSize: true,
3558
- params: SMOOTH_AREA_PARAMS,
3559
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
3743
+ params: LABELED_AREA_PARAMS,
3744
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
3560
3745
  };
3561
3746
  /**
3562
3747
  * Creates a Target area of interest control measure: a closed area through
3563
- * `positions` labeled at its center with "TAI "-prefixed `T` (Field T) —
3564
- * the sole label row, per {@link labeledAreaFeatures}.
3748
+ * `positions` labeled at its center with "TAI "-prefixed `T` (Field T) plus
3749
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
3565
3750
  */
3566
- function createTargetAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
3567
- return labeledAreaFeatures(positions, options, { name: composeAreaName("TAI", textAmplifiers.T) });
3751
+ function createTargetAreaOfInterest(positions, options = {}, textAmplifiers = {}, context = {}) {
3752
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "TAI"), context.amplifierPlacements);
3568
3753
  }
3569
3754
  const TARGET_AREA_OF_INTEREST = defineControlMeasure({
3570
3755
  metadata: TARGET_AREA_OF_INTEREST_METADATA,
@@ -3618,21 +3803,25 @@ const AIRFIELD_ZONE_METADATA = {
3618
3803
  ],
3619
3804
  drawRule: "Area1",
3620
3805
  capturesLabelSize: true,
3621
- params: [...SMOOTH_AREA_PARAMS, {
3622
- key: "glyphSizeRatio",
3623
- label: "Runway size",
3624
- description: "Size of the runway glyph as a ratio of the area's width.",
3625
- type: "number",
3626
- min: .2,
3627
- max: .9,
3628
- step: .05
3629
- }],
3806
+ params: [
3807
+ ...AREA_SMOOTH_PARAMS,
3808
+ {
3809
+ key: "glyphSizeRatio",
3810
+ label: "Runway size",
3811
+ description: "Size of the runway glyph as a ratio of the area's width.",
3812
+ type: "number",
3813
+ min: .2,
3814
+ max: .9,
3815
+ step: .05
3816
+ },
3817
+ AREA_LABEL_PADDING_PARAM
3818
+ ],
3630
3819
  textAmplifiers: [{
3631
3820
  key: "H",
3632
3821
  label: "Additional information",
3633
3822
  description: "Field H — airfield type, runway length and other information, placed outside the area's east edge.",
3634
3823
  maxLength: 20
3635
- }]
3824
+ }, AREA_HOSTILE_AMPLIFIER]
3636
3825
  };
3637
3826
  /**
3638
3827
  * Creates an Airfield zone control measure: a closed area through
@@ -3643,12 +3832,14 @@ const AIRFIELD_ZONE_METADATA = {
3643
3832
  * @param positions - Area anchor points (≥3); the input contract is enforced
3644
3833
  * at the render seam (ADR-0014).
3645
3834
  * @param options - {@link AirfieldZoneOptions}.
3646
- * @param textAmplifiers - Field H only.
3835
+ * @param textAmplifiers - Field H (placed east of the area) plus an optional
3836
+ * Field N "ENY" hostile marker on the west/east edges.
3647
3837
  */
3648
- function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
3838
+ function createAirfieldZone(positions, options = {}, textAmplifiers = {}, context = {}) {
3649
3839
  const { smooth = DEFAULT_AIRFIELD_ZONE_OPTIONS.smooth, smoothResolution = DEFAULT_AIRFIELD_ZONE_OPTIONS.smoothResolution, glyphSizeRatio = DEFAULT_AIRFIELD_ZONE_OPTIONS.glyphSizeRatio, ...labelOptions } = options;
3650
3840
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$8);
3651
- const features = [boundaryFeature(verts, [])];
3841
+ const enyLabels = [];
3842
+ const features = [boundaryFeature(verts, textAmplifiers.N ? pushAreaLabels(enyLabels, verts, { hostile: textAmplifiers.N }, labelOptions, context.amplifierPlacements) : [])];
3652
3843
  let minX = Infinity;
3653
3844
  let maxX = -Infinity;
3654
3845
  for (const [x] of verts) {
@@ -3680,6 +3871,7 @@ function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
3680
3871
  pushLabel(labelFeatures, [maxX + H_LABEL_OFFSET_RATIO * resolveLabelOffsetMeters(labelOptions, 1), cy], textAmplifiers.H, HORIZONTAL_LABEL_ROTATION, labelSizeProps(labelOptions), "start", "H");
3681
3872
  features.push(...labelFeatures);
3682
3873
  }
3874
+ features.push(...enyLabels);
3683
3875
  return {
3684
3876
  type: "FeatureCollection",
3685
3877
  features
@@ -3723,13 +3915,13 @@ const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
3723
3915
  * before carving the boundary, so either (or both) opens it into a gapped
3724
3916
  * `MultiLineString`; with neither, the boundary is a closed `Polygon`.
3725
3917
  */
3726
- function echelonLabeledAreaFeatures(positions, options, texts) {
3918
+ function echelonLabeledAreaFeatures(positions, options, texts, amplifierPlacements) {
3727
3919
  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;
3728
3920
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
3729
3921
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
3730
3922
  const { strokes, fills, gaps: echelonGaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
3731
3923
  const labelFeatures = [];
3732
- const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions);
3924
+ const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements);
3733
3925
  const features = [boundaryFeature(verts, [...echelonGaps, ...enyGaps])];
3734
3926
  if (strokes.length > 0) features.push({
3735
3927
  type: "Feature",
@@ -3798,8 +3990,8 @@ const BASE_CAMP_METADATA = {
3798
3990
  * amplifier rows, and an optional Field B echelon glyph straddling the
3799
3991
  * boundary, per {@link echelonLabeledAreaFeatures}.
3800
3992
  */
3801
- function createBaseCamp(positions, options = {}, textAmplifiers = {}) {
3802
- return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "BC"));
3993
+ function createBaseCamp(positions, options = {}, textAmplifiers = {}, context = {}) {
3994
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "BC"), context.amplifierPlacements);
3803
3995
  }
3804
3996
  const BASE_CAMP = defineControlMeasure({
3805
3997
  metadata: BASE_CAMP_METADATA,
@@ -3851,8 +4043,8 @@ const GUERRILLA_BASE_METADATA = {
3851
4043
  * `H`/`W`/`W1`/`N` amplifier rows, and an optional Field B echelon glyph
3852
4044
  * straddling the boundary, per {@link echelonLabeledAreaFeatures}.
3853
4045
  */
3854
- function createGuerrillaBase(positions, options = {}, textAmplifiers = {}) {
3855
- return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "GB"));
4046
+ function createGuerrillaBase(positions, options = {}, textAmplifiers = {}, context = {}) {
4047
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "GB"), context.amplifierPlacements);
3856
4048
  }
3857
4049
  const GUERRILLA_BASE = defineControlMeasure({
3858
4050
  metadata: GUERRILLA_BASE_METADATA,
@@ -3908,8 +4100,8 @@ const GENERIC_C2_AREA_METADATA = {
3908
4100
  * through `positions` labeled at its center with the bare `T` (Field T) plus
3909
4101
  * `H`/`W`/`W1`/`N` amplifier rows — no prefix, per {@link labeledAreaFeatures}.
3910
4102
  */
3911
- function createGenericC2Area(positions, options = {}, textAmplifiers = {}) {
3912
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers));
4103
+ function createGenericC2Area(positions, options = {}, textAmplifiers = {}, context = {}) {
4104
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers), context.amplifierPlacements);
3913
4105
  }
3914
4106
  const GENERIC_C2_AREA = defineControlMeasure({
3915
4107
  metadata: GENERIC_C2_AREA_METADATA,
@@ -3958,8 +4150,8 @@ const ASSEMBLY_AREA_METADATA = {
3958
4150
  * `positions` labeled at its center with "AA "-prefixed `T` (Field T) plus
3959
4151
  * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
3960
4152
  */
3961
- function createAssemblyArea(positions, options = {}, textAmplifiers = {}) {
3962
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AA"));
4153
+ function createAssemblyArea(positions, options = {}, textAmplifiers = {}, context = {}) {
4154
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AA"), context.amplifierPlacements);
3963
4155
  }
3964
4156
  const ASSEMBLY_AREA = defineControlMeasure({
3965
4157
  metadata: ASSEMBLY_AREA_METADATA,
@@ -4640,7 +4832,7 @@ const BATTLE_POSITION_METADATA = {
4640
4832
  drawRule: "Area1",
4641
4833
  capturesLabelSize: true,
4642
4834
  params: ECHELON_AREA_PARAMS,
4643
- textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
4835
+ textAmplifiers: AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS
4644
4836
  };
4645
4837
  /**
4646
4838
  * Creates a Battle Position control measure: a closed area through `positions`
@@ -4650,16 +4842,22 @@ const BATTLE_POSITION_METADATA = {
4650
4842
  * @param positions - Area anchor points (≥3); the input contract is enforced at
4651
4843
  * the render seam (ADR-0014).
4652
4844
  * @param options - {@link BattlePositionOptions}.
4653
- * @param textAmplifiers - Field T only, centered in the area (no H/W rows —
4654
- * this measure's template pairs the designation with the Field B glyph,
4655
- * not the plain L-15 boundary template).
4845
+ * @param textAmplifiers - Field T (designation, centered in the area) plus an
4846
+ * optional Field N "ENY" hostile marker at the west/east edges (no H/W rows —
4847
+ * this measure's template pairs the designation with the Field B glyph, not
4848
+ * the plain L-15 boundary template).
4656
4849
  */
4657
- function createBattlePosition(positions, options = {}, textAmplifiers = {}) {
4850
+ function createBattlePosition(positions, options = {}, textAmplifiers = {}, context = {}) {
4658
4851
  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;
4659
4852
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
4660
4853
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6);
4661
4854
  const { strokes, fills, gaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
4662
- const boundaryCoords = buildGappedLine(verts, gaps);
4855
+ const labelFeatures = [];
4856
+ const enyGaps = pushAreaLabels(labelFeatures, verts, {
4857
+ name: textAmplifiers.T,
4858
+ hostile: textAmplifiers.N
4859
+ }, options, context.amplifierPlacements);
4860
+ const boundaryCoords = buildGappedLine(verts, [...gaps, ...enyGaps]);
4663
4861
  const features = [];
4664
4862
  if (boundaryCoords.length > 0) features.push({
4665
4863
  type: "Feature",
@@ -4688,8 +4886,6 @@ function createBattlePosition(positions, options = {}, textAmplifiers = {}) {
4688
4886
  coordinates: fills.map((ring) => [ring])
4689
4887
  }
4690
4888
  });
4691
- const labelFeatures = [];
4692
- pushAreaLabels(labelFeatures, verts, { name: textAmplifiers.T }, options);
4693
4889
  features.push(...labelFeatures);
4694
4890
  return {
4695
4891
  type: "FeatureCollection",
@@ -5053,6 +5249,10 @@ function normalizeSmoothResolution$1(value) {
5053
5249
  //#endregion
5054
5250
  //#region src/generators/cm11-command-control-lines/boundary.ts
5055
5251
  const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
5252
+ function resolveLabelRepetitions(value) {
5253
+ const numeric = Number(value ?? 1);
5254
+ return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
5255
+ }
5056
5256
  /** Default options for the Boundary control measure. */
5057
5257
  const DEFAULT_BOUNDARY_OPTIONS = {
5058
5258
  echelon: "battalion",
@@ -5061,6 +5261,7 @@ const DEFAULT_BOUNDARY_OPTIONS = {
5061
5261
  echelonPadding: .3,
5062
5262
  labelRepetitions: 1,
5063
5263
  labelSpacing: 1,
5264
+ labelPosition: .5,
5064
5265
  labelPadding: .4,
5065
5266
  labelOrientation: "along",
5066
5267
  smooth: false,
@@ -5143,6 +5344,16 @@ const BOUNDARY_METADATA = {
5143
5344
  max: 5,
5144
5345
  step: 1
5145
5346
  },
5347
+ {
5348
+ key: "labelPosition",
5349
+ label: "Label position",
5350
+ description: "Position of the label group along the boundary, from the first point (0) to the last (1)",
5351
+ type: "number",
5352
+ min: 0,
5353
+ max: 1,
5354
+ step: .01,
5355
+ visibleWhen: (opts) => resolveLabelRepetitions(opts.labelRepetitions) === 1
5356
+ },
5146
5357
  {
5147
5358
  key: "labelSpacing",
5148
5359
  label: "Label spacing",
@@ -5151,7 +5362,7 @@ const BOUNDARY_METADATA = {
5151
5362
  min: 0,
5152
5363
  max: 2,
5153
5364
  step: .05,
5154
- visibleWhen: (opts) => Number(opts.labelRepetitions ?? 1) > 1
5365
+ visibleWhen: (opts) => resolveLabelRepetitions(opts.labelRepetitions) > 1
5155
5366
  },
5156
5367
  {
5157
5368
  key: "labelPadding",
@@ -5214,7 +5425,7 @@ const BOUNDARY_METADATA = {
5214
5425
  * unit 1 (left-of-travel side), `T1`/`AS1` label unit 2 (right-of-travel side).
5215
5426
  */
5216
5427
  function createBoundary(positions, options = {}, textAmplifiers = {}) {
5217
- 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, labelPadding = DEFAULT_BOUNDARY_OPTIONS.labelPadding, labelOrientation = DEFAULT_BOUNDARY_OPTIONS.labelOrientation, smooth = DEFAULT_BOUNDARY_OPTIONS.smooth, smoothResolution = DEFAULT_BOUNDARY_OPTIONS.smoothResolution } = options;
5428
+ 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;
5218
5429
  const unit1Designator = textAmplifiers.T ?? "";
5219
5430
  const unit1Country = textAmplifiers.AS ?? "";
5220
5431
  const unit2Designator = textAmplifiers.T1 ?? "";
@@ -5222,7 +5433,8 @@ function createBoundary(positions, options = {}, textAmplifiers = {}) {
5222
5433
  let h = echelonSize;
5223
5434
  if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
5224
5435
  h = Math.max(EPSILON, h);
5225
- const reps = Math.max(1, Math.round(labelRepetitions));
5436
+ const reps = resolveLabelRepetitions(labelRepetitions);
5437
+ const singleGroupPosition = clampLinePosition(labelPosition, DEFAULT_BOUNDARY_OPTIONS.labelPosition);
5226
5438
  const spec = resolveEchelon(echelon);
5227
5439
  const labelSize = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0 ? { textSizePixels: echelonSizePixels } : { textSizeMeters: h };
5228
5440
  const rawVerts = positions.map((p) => project(p[0], p[1]));
@@ -5234,7 +5446,7 @@ function createBoundary(positions, options = {}, textAmplifiers = {}) {
5234
5446
  const { segments: segs, totalLength } = polylineSegments(verts);
5235
5447
  if (totalLength >= 1e-6) for (let k = 0; k < reps; k++) {
5236
5448
  const evenFraction = (k + 1) / (reps + 1);
5237
- const fraction = Math.min(1, Math.max(0, .5 + (evenFraction - .5) * labelSpacing));
5449
+ const fraction = Math.min(1, Math.max(0, reps === 1 ? singleGroupPosition : .5 + (evenFraction - .5) * labelSpacing));
5238
5450
  const target = fraction * totalLength;
5239
5451
  const frame = pointAlongPolyline(segs, totalLength, fraction);
5240
5452
  const center = frame.point;
@@ -7669,8 +7881,8 @@ const DROP_ZONE_METADATA = {
7669
7881
  * labeled at its center with "DZ "-prefixed `T` (Field T) plus an `N`
7670
7882
  * (Field N) ENY marker, per {@link labeledAreaFeatures}.
7671
7883
  */
7672
- function createDropZone(positions, options = {}, textAmplifiers = {}) {
7673
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "DZ"));
7884
+ function createDropZone(positions, options = {}, textAmplifiers = {}, context = {}) {
7885
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "DZ"), context.amplifierPlacements);
7674
7886
  }
7675
7887
  const DROP_ZONE = defineControlMeasure({
7676
7888
  metadata: DROP_ZONE_METADATA,
@@ -7784,11 +7996,11 @@ function buildBarbs(perimeter, spacing, barbLength, gaps) {
7784
7996
  * Creates an Encirclement tactical graphic: a complete smooth boundary ring
7785
7997
  * with outward arrowhead barbs resting on it. The boundary is an unfilled
7786
7998
  * Polygon; the barbs are open "V" strokes (a MultiLineString) whose base is the
7787
- * boundary arc itself. When a Field N (ENY) marker is set, it sits ON the
7788
- * boundary and masks it with a knockout gap (mirroring Field B's echelon
7789
- * glyph, ADR-0023) — the boundary becomes a gapped MultiLineString instead of
7790
- * a closed Polygon, and any barb whose center falls inside the gap is
7791
- * suppressed; the no-`N` case is unaffected.
7999
+ * boundary arc itself. When a Field N (ENY) marker is set, it masks the
8000
+ * boundary with a knockout gap wherever its text box touches the ring
8001
+ * (mirroring Field B's echelon glyph, ADR-0023) — the boundary becomes a
8002
+ * gapped MultiLineString instead of a closed Polygon, and any barb whose
8003
+ * center falls inside the gap is suppressed; the no-`N` case is unaffected.
7792
8004
  *
7793
8005
  * @param positions - Area anchor points (>=3); the input contract is enforced
7794
8006
  * at the render seam (ADR-0014).
@@ -7796,12 +8008,12 @@ function buildBarbs(perimeter, spacing, barbLength, gaps) {
7796
8008
  * @param textAmplifiers - Field N only — rendered as "ENY" markers at the
7797
8009
  * ring's west and east edges, per the doctrinal hostile example.
7798
8010
  */
7799
- function createEncirclement(positions, options = {}, textAmplifiers = {}) {
8011
+ function createEncirclement(positions, options = {}, textAmplifiers = {}, context = {}) {
7800
8012
  const { barbSpacingRatio = DEFAULT_BARB_SPACING_RATIO, barbLengthRatio = DEFAULT_BARB_LENGTH_RATIO, smooth = DEFAULT_ENCIRCLEMENT_OPTIONS.smooth, smoothResolution = DEFAULT_ENCIRCLEMENT_OPTIONS.smoothResolution } = options;
7801
8013
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$2);
7802
8014
  const perimeter = buildAreaPerimeter(verts);
7803
8015
  const labelFeatures = [];
7804
- const gaps = pushAreaLabels(labelFeatures, verts, { hostile: textAmplifiers.N }, options);
8016
+ const gaps = pushAreaLabels(labelFeatures, verts, { hostile: textAmplifiers.N }, options, context.amplifierPlacements);
7805
8017
  const radius = meanRadius(verts);
7806
8018
  const barbLength = radius * Math.max(0, barbLengthRatio);
7807
8019
  const barbs = buildBarbs(perimeter, radius * Math.max(0, barbSpacingRatio), barbLength, gaps);
@@ -7876,8 +8088,8 @@ const EXTRACTION_ZONE_METADATA = {
7876
8088
  * `positions` labeled at its center with "EZ "-prefixed `T` (Field T) plus
7877
8089
  * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
7878
8090
  */
7879
- function createExtractionZone(positions, options = {}, textAmplifiers = {}) {
7880
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "EZ"));
8091
+ function createExtractionZone(positions, options = {}, textAmplifiers = {}, context = {}) {
8092
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "EZ"), context.amplifierPlacements);
7881
8093
  }
7882
8094
  const EXTRACTION_ZONE = defineControlMeasure({
7883
8095
  metadata: EXTRACTION_ZONE_METADATA,
@@ -8773,11 +8985,15 @@ const FORTIFIED_AREA_METADATA = {
8773
8985
  value: "151000",
8774
8986
  minCoordinates: 3,
8775
8987
  geometry: "area",
8776
- geometryTypes: ["Polygon", "Point"],
8988
+ geometryTypes: [
8989
+ "Polygon",
8990
+ "MultiLineString",
8991
+ "Point"
8992
+ ],
8777
8993
  drawRule: "Area1",
8778
8994
  capturesLabelSize: true,
8779
8995
  params: [...FORTIFIED_AREA_PARAMS, AREA_LABEL_PADDING_PARAM],
8780
- textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_HOSTILE
8996
+ textAmplifiers: AREA_TEXT_AMPLIFIERS
8781
8997
  };
8782
8998
  /**
8783
8999
  * Creates a Fortified Area tactical graphic.
@@ -8791,12 +9007,15 @@ const FORTIFIED_AREA_METADATA = {
8791
9007
  * @param options - Configuration options for the graphic
8792
9008
  * @param textAmplifiers - Field T (bare, no prefix — "TANGO", not "FA TANGO")
8793
9009
  * plus Field H/W/W1, centered on the pre-teeth boundary ring (see
8794
- * `boundaryInput` below); no Field N — Fortified Area's L-15 template
8795
- * carries no ENY marker.
8796
- * @returns A GeoJSON FeatureCollection containing the castellated Polygon
8797
- * plus any label Points
8798
- */
8799
- function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
9010
+ * `boundaryInput` below), and an optional Field N "ENY" hostile marker on
9011
+ * that ring's west/east edges, masking the castellated boundary beneath it.
9012
+ * @param context - Persisted amplifier placements used to move or restore each
9013
+ * ENY cutout with its independently dragged label.
9014
+ * @returns A GeoJSON FeatureCollection containing the castellated boundary
9015
+ * (a Polygon normally, or a gapped MultiLineString with Field N) plus any
9016
+ * label Points
9017
+ */
9018
+ function createFortifiedArea(positions, options = {}, textAmplifiers = {}, context = {}) {
8800
9019
  const { smooth = DEFAULT_FORTIFIED_AREA_OPTIONS.smooth, smoothResolution = DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution } = options;
8801
9020
  const safeSize = calculateEffectiveSize(options);
8802
9021
  const projected = positions.map((p) => project(p[0], p[1]));
@@ -8809,21 +9028,20 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
8809
9028
  if (vecMag(vecSub(firstPt, lastPt)) > 1e-6) boundaryPoints.push(firstPt);
8810
9029
  else boundaryPoints[boundaryPoints.length - 1] = firstPt;
8811
9030
  }
9031
+ const labelFeatures = [];
8812
9032
  const features = [{
8813
- type: "Feature",
8814
- properties: {},
8815
- geometry: {
8816
- type: "Polygon",
8817
- coordinates: [boundaryPoints.map((p) => unproject(p[0], p[1]))]
8818
- }
9033
+ ...boundaryFeature(boundaryPoints, pushAreaLabels(labelFeatures, boundaryInput, {
9034
+ name: textAmplifiers.T,
9035
+ additionalInformation: textAmplifiers.H,
9036
+ dtgStart: textAmplifiers.W,
9037
+ dtgEnd: textAmplifiers.W1,
9038
+ hostile: textAmplifiers.N
9039
+ }, options, context.amplifierPlacements, {
9040
+ ringVerts: boundaryPoints,
9041
+ extraClearanceMeters: 3 * safeSize
9042
+ })),
9043
+ properties: {}
8819
9044
  }];
8820
- const labelFeatures = [];
8821
- pushAreaLabels(labelFeatures, boundaryInput, {
8822
- name: textAmplifiers.T,
8823
- additionalInformation: textAmplifiers.H,
8824
- dtgStart: textAmplifiers.W,
8825
- dtgEnd: textAmplifiers.W1
8826
- }, options);
8827
9045
  features.push(...labelFeatures);
8828
9046
  return {
8829
9047
  type: "FeatureCollection",
@@ -8857,9 +9075,6 @@ const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
8857
9075
  dtgPosition: .62,
8858
9076
  labelPadding: 0
8859
9077
  };
8860
- function midpoint(a, b) {
8861
- return vecScale(vecAdd(a, b), .5);
8862
- }
8863
9078
  function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
8864
9079
  const resolved = {
8865
9080
  ...DEFAULT_MANEUVER_ARROW_TASK_OPTIONS,
@@ -8877,7 +9092,7 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
8877
9092
  geometry.headRing[2],
8878
9093
  ...[...geometry.shaftRight].reverse()
8879
9094
  ];
8880
- const centerline = geometry.shaftLeft.map((left, index) => midpoint(left, geometry.shaftRight[index]));
9095
+ const centerline = geometry.shaftCenterline;
8881
9096
  const { segments, totalLength } = polylineSegments(centerline);
8882
9097
  if (segments.length === 0) return {
8883
9098
  type: "FeatureCollection",
@@ -9243,8 +9458,8 @@ const JOINT_TACTICAL_ACTION_AREA_METADATA = {
9243
9458
  * (Field T) plus `H`/`W`/`W1`/`N` amplifier rows, per
9244
9459
  * {@link labeledAreaFeatures}.
9245
9460
  */
9246
- function createJointTacticalActionArea(positions, options = {}, textAmplifiers = {}) {
9247
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "JTAA -", ""));
9461
+ function createJointTacticalActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
9462
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "JTAA -", ""), context.amplifierPlacements);
9248
9463
  }
9249
9464
  const JOINT_TACTICAL_ACTION_AREA = defineControlMeasure({
9250
9465
  metadata: JOINT_TACTICAL_ACTION_AREA_METADATA,
@@ -9297,8 +9512,8 @@ const LANDING_ZONE_METADATA = {
9297
9512
  * labeled at its center with "LZ "-prefixed `T` (Field T) plus `H`/`W`/`W1`/`N`
9298
9513
  * amplifier rows, per {@link labeledAreaFeatures}.
9299
9514
  */
9300
- function createLandingZone(positions, options = {}, textAmplifiers = {}) {
9301
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "LZ"));
9515
+ function createLandingZone(positions, options = {}, textAmplifiers = {}, context = {}) {
9516
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "LZ"), context.amplifierPlacements);
9302
9517
  }
9303
9518
  const LANDING_ZONE = defineControlMeasure({
9304
9519
  metadata: LANDING_ZONE_METADATA,
@@ -9817,8 +10032,8 @@ const PICKUP_ZONE_METADATA = {
9817
10032
  * labeled at its center with "PZ "-prefixed `T` (Field T) plus `H`/`W`/`W1`/`N`
9818
10033
  * amplifier rows, per {@link labeledAreaFeatures}.
9819
10034
  */
9820
- function createPickupZone(positions, options = {}, textAmplifiers = {}) {
9821
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "PZ"));
10035
+ function createPickupZone(positions, options = {}, textAmplifiers = {}, context = {}) {
10036
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "PZ"), context.amplifierPlacements);
9822
10037
  }
9823
10038
  const PICKUP_ZONE = defineControlMeasure({
9824
10039
  metadata: PICKUP_ZONE_METADATA,
@@ -9852,19 +10067,23 @@ const ASSAULT_POSITION_METADATA = {
9852
10067
  value: "151500",
9853
10068
  minCoordinates: 3,
9854
10069
  geometry: "area",
9855
- geometryTypes: ["Polygon", "Point"],
10070
+ geometryTypes: [
10071
+ "Polygon",
10072
+ "MultiLineString",
10073
+ "Point"
10074
+ ],
9856
10075
  drawRule: "Area1",
9857
10076
  capturesLabelSize: true,
9858
- params: SMOOTH_AREA_PARAMS,
9859
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10077
+ params: LABELED_AREA_PARAMS,
10078
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9860
10079
  };
9861
10080
  /**
9862
10081
  * Creates an Assault position control measure: a closed area through
9863
- * `positions` labeled at its center with "ASLT "-prefixed `T` (Field T) —
9864
- * the sole label row, per {@link labeledAreaFeatures}.
10082
+ * `positions` labeled at its center with "ASLT "-prefixed `T` (Field T) plus
10083
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9865
10084
  */
9866
- function createAssaultPosition(positions, options = {}, textAmplifiers = {}) {
9867
- return labeledAreaFeatures(positions, options, { name: composeAreaName("ASLT", textAmplifiers.T) });
10085
+ function createAssaultPosition(positions, options = {}, textAmplifiers = {}, context = {}) {
10086
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "ASLT"), context.amplifierPlacements);
9868
10087
  }
9869
10088
  const ASSAULT_POSITION = defineControlMeasure({
9870
10089
  metadata: ASSAULT_POSITION_METADATA,
@@ -9898,19 +10117,23 @@ const ATTACK_POSITION_METADATA = {
9898
10117
  value: "151600",
9899
10118
  minCoordinates: 3,
9900
10119
  geometry: "area",
9901
- geometryTypes: ["Polygon", "Point"],
10120
+ geometryTypes: [
10121
+ "Polygon",
10122
+ "MultiLineString",
10123
+ "Point"
10124
+ ],
9902
10125
  drawRule: "Area1",
9903
10126
  capturesLabelSize: true,
9904
- params: SMOOTH_AREA_PARAMS,
9905
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10127
+ params: LABELED_AREA_PARAMS,
10128
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9906
10129
  };
9907
10130
  /**
9908
10131
  * Creates an Attack position control measure: a closed area through
9909
- * `positions` labeled at its center with "ATK "-prefixed `T` (Field T) —
9910
- * the sole label row, per {@link labeledAreaFeatures}.
10132
+ * `positions` labeled at its center with "ATK "-prefixed `T` (Field T) plus
10133
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9911
10134
  */
9912
- function createAttackPosition(positions, options = {}, textAmplifiers = {}) {
9913
- return labeledAreaFeatures(positions, options, { name: composeAreaName("ATK", textAmplifiers.T) });
10135
+ function createAttackPosition(positions, options = {}, textAmplifiers = {}, context = {}) {
10136
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "ATK"), context.amplifierPlacements);
9914
10137
  }
9915
10138
  const ATTACK_POSITION = defineControlMeasure({
9916
10139
  metadata: ATTACK_POSITION_METADATA,
@@ -9944,19 +10167,23 @@ const OBJECTIVE_AREA_METADATA = {
9944
10167
  value: "151700",
9945
10168
  minCoordinates: 3,
9946
10169
  geometry: "area",
9947
- geometryTypes: ["Polygon", "Point"],
10170
+ geometryTypes: [
10171
+ "Polygon",
10172
+ "MultiLineString",
10173
+ "Point"
10174
+ ],
9948
10175
  drawRule: "Area1",
9949
10176
  capturesLabelSize: true,
9950
- params: SMOOTH_AREA_PARAMS,
9951
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10177
+ params: LABELED_AREA_PARAMS,
10178
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9952
10179
  };
9953
10180
  /**
9954
10181
  * Creates an Objective area control measure: a closed area through
9955
- * `positions` labeled at its center with "OBJ "-prefixed `T` (Field T) —
9956
- * the sole label row, per {@link labeledAreaFeatures}.
10182
+ * `positions` labeled at its center with "OBJ "-prefixed `T` (Field T) plus
10183
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9957
10184
  */
9958
- function createObjectiveArea(positions, options = {}, textAmplifiers = {}) {
9959
- return labeledAreaFeatures(positions, options, { name: composeAreaName("OBJ", textAmplifiers.T) });
10185
+ function createObjectiveArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10186
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "OBJ"), context.amplifierPlacements);
9960
10187
  }
9961
10188
  const OBJECTIVE_AREA = defineControlMeasure({
9962
10189
  metadata: OBJECTIVE_AREA_METADATA,
@@ -10079,7 +10306,7 @@ const STRONG_POINT_METADATA = {
10079
10306
  drawRule: "Area1",
10080
10307
  capturesLabelSize: true,
10081
10308
  params: ECHELON_AREA_PARAMS,
10082
- textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
10309
+ textAmplifiers: AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS
10083
10310
  };
10084
10311
  /**
10085
10312
  * Outward perimeter tics: a stroke every `h` meters along the ring, each `h`
@@ -10114,17 +10341,24 @@ function buildStrongPointTics(perimeter, gaps, h) {
10114
10341
  * @param positions - Area anchor points (>=3); the input contract is enforced
10115
10342
  * at the render seam (ADR-0014).
10116
10343
  * @param options - {@link StrongPointOptions}.
10117
- * @param textAmplifiers - Field T only, centered in the area, same as Battle
10118
- * Position.
10344
+ * @param textAmplifiers - Field T (designation, centered in the area) plus an
10345
+ * optional Field N "ENY" hostile marker at the west/east edges, same as
10346
+ * Battle Position.
10119
10347
  */
10120
- function createStrongPoint(positions, options = {}, textAmplifiers = {}) {
10348
+ function createStrongPoint(positions, options = {}, textAmplifiers = {}, context = {}) {
10121
10349
  const { echelon = DEFAULT_STRONG_POINT_OPTIONS.echelon, echelonSize = DEFAULT_STRONG_POINT_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_STRONG_POINT_OPTIONS.echelonPadding, echelonPosition = DEFAULT_STRONG_POINT_OPTIONS.echelonPosition, echelonAnchor, metersPerPixel, smooth = DEFAULT_STRONG_POINT_OPTIONS.smooth, smoothResolution = DEFAULT_STRONG_POINT_OPTIONS.smoothResolution } = options;
10122
10350
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
10123
10351
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION);
10124
10352
  const perimeter = buildAreaPerimeter(verts);
10125
10353
  const { strokes, fills, gaps } = placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, echelonAnchor);
10126
- const boundaryCoords = buildGappedLine(verts, gaps);
10127
- const tics = buildStrongPointTics(perimeter, gaps, h);
10354
+ const labelFeatures = [];
10355
+ const enyGaps = pushAreaLabels(labelFeatures, verts, {
10356
+ name: textAmplifiers.T,
10357
+ hostile: textAmplifiers.N
10358
+ }, options, context.amplifierPlacements);
10359
+ const allGaps = [...gaps, ...enyGaps];
10360
+ const boundaryCoords = buildGappedLine(verts, allGaps);
10361
+ const tics = buildStrongPointTics(perimeter, allGaps, h);
10128
10362
  const features = [];
10129
10363
  if (boundaryCoords.length > 0) features.push({
10130
10364
  type: "Feature",
@@ -10161,8 +10395,6 @@ function createStrongPoint(positions, options = {}, textAmplifiers = {}) {
10161
10395
  coordinates: fills.map((ring) => [ring])
10162
10396
  }
10163
10397
  });
10164
- const labelFeatures = [];
10165
- pushAreaLabels(labelFeatures, verts, { name: textAmplifiers.T }, options);
10166
10398
  features.push(...labelFeatures);
10167
10399
  return {
10168
10400
  type: "FeatureCollection",
@@ -10222,8 +10454,8 @@ const SUBMARINE_ACTION_AREA_METADATA = {
10222
10454
  * `positions` labeled at its center with "SAA -"-prefixed `T` (Field T) plus
10223
10455
  * `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
10224
10456
  */
10225
- function createSubmarineActionArea(positions, options = {}, textAmplifiers = {}) {
10226
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SAA -", ""));
10457
+ function createSubmarineActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10458
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SAA -", ""), context.amplifierPlacements);
10227
10459
  }
10228
10460
  const SUBMARINE_ACTION_AREA = defineControlMeasure({
10229
10461
  metadata: SUBMARINE_ACTION_AREA_METADATA,
@@ -10277,8 +10509,8 @@ const SUBMARINE_GENERATED_ACTION_AREA_METADATA = {
10277
10509
  * through `positions` labeled at its center with "SGSA -"-prefixed `T`
10278
10510
  * (Field T) plus `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
10279
10511
  */
10280
- function createSubmarineGeneratedActionArea(positions, options = {}, textAmplifiers = {}) {
10281
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SGSA -", ""));
10512
+ function createSubmarineGeneratedActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10513
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SGSA -", ""), context.amplifierPlacements);
10282
10514
  }
10283
10515
  const SUBMARINE_GENERATED_ACTION_AREA = defineControlMeasure({
10284
10516
  metadata: SUBMARINE_GENERATED_ACTION_AREA_METADATA,
@@ -10860,141 +11092,6 @@ function applyLayer(target, layer) {
10860
11092
  }
10861
11093
  }
10862
11094
  //#endregion
10863
- //#region src/text-amplifiers.ts
10864
- /**
10865
- * The vocabulary of text amplifier fields: doctrinal code, milsymbol-style
10866
- * friendly alias, and a human-readable label. The single source of truth for
10867
- * both the runtime catalog and the derived alias→code lookup used by
10868
- * {@link normalizeTextAmplifiers}.
10869
- */
10870
- const TEXT_AMPLIFIER_FIELDS = {
10871
- C: {
10872
- alias: "quantity",
10873
- label: "Quantity"
10874
- },
10875
- H: {
10876
- alias: "additionalInformation",
10877
- label: "Additional information"
10878
- },
10879
- N: {
10880
- alias: "hostile",
10881
- label: "Hostile (ENY) marker"
10882
- },
10883
- T: {
10884
- alias: "uniqueDesignation",
10885
- label: "Unique designation"
10886
- },
10887
- V: {
10888
- alias: "type",
10889
- label: "Equipment/type"
10890
- },
10891
- W: {
10892
- alias: "dtg",
10893
- label: "Date-time group / O/O"
10894
- },
10895
- X: {
10896
- alias: "altitudeDepth",
10897
- label: "Altitude/depth"
10898
- },
10899
- Y: {
10900
- alias: "location",
10901
- label: "Location"
10902
- },
10903
- AM: {
10904
- alias: "distance",
10905
- label: "Distance/range/radius"
10906
- },
10907
- AN: {
10908
- alias: "azimuth",
10909
- label: "Azimuth"
10910
- },
10911
- AP: {
10912
- alias: "targetNumber",
10913
- label: "Target number"
10914
- },
10915
- AS: {
10916
- alias: "country",
10917
- label: "Country code"
10918
- }
10919
- };
10920
- /** Derived alias→code lookup, so the vocabulary table stays the single source of truth. */
10921
- const CODE_BY_ALIAS = Object.fromEntries(Object.entries(TEXT_AMPLIFIER_FIELDS).map(([code, { alias }]) => [alias, code]));
10922
- const CANONICAL_KEYS = new Set(Object.keys(TEXT_AMPLIFIER_FIELDS).flatMap((code) => [
10923
- code,
10924
- `${code}1`,
10925
- `${code}2`
10926
- ]));
10927
- /**
10928
- * Resolves a raw placement entry to its object form: a bare `Position` is
10929
- * legacy shorthand for `{ position }`; an object form passes through only the
10930
- * fields that are actually valid (`position` must be a finite `[lon, lat]`,
10931
- * `rotation` a finite number); `undefined` resolves to `{}`.
10932
- */
10933
- function resolveAmplifierPlacement(value) {
10934
- if (value === void 0) return {};
10935
- if (Array.isArray(value)) return isValidPosition(value) ? { position: value } : {};
10936
- const result = {};
10937
- if (value.position !== void 0 && isValidPosition(value.position)) result.position = value.position;
10938
- if (typeof value.rotation === "number" && Number.isFinite(value.rotation)) result.rotation = value.rotation;
10939
- return result;
10940
- }
10941
- const EMPTY_TEXT_AMPLIFIERS = Object.freeze({});
10942
- /** Splits a key like `"country1"` into its base (`"country"`) and suffix (`"1"`). */
10943
- function splitSuffix(key) {
10944
- const last = key.charAt(key.length - 1);
10945
- if (last === "1" || last === "2") return {
10946
- base: key.slice(0, -1),
10947
- suffix: last
10948
- };
10949
- return {
10950
- base: key,
10951
- suffix: ""
10952
- };
10953
- }
10954
- /**
10955
- * Resolves any accepted key (canonical or friendly alias, with an optional
10956
- * `1`/`2` suffix) to its canonical key, or `undefined` if unknown — e.g.
10957
- * `"T"` → `"T"`, `"uniqueDesignation1"` → `"T1"`, `"bogus"` → `undefined`.
10958
- */
10959
- function canonicalTextAmplifierKey(key) {
10960
- if (CANONICAL_KEYS.has(key)) return key;
10961
- const { base, suffix } = splitSuffix(key);
10962
- const code = CODE_BY_ALIAS[base];
10963
- return code ? `${code}${suffix}` : void 0;
10964
- }
10965
- /**
10966
- * Resolves a `TextAmplifiers` record (canonical keys, friendly aliases, or a
10967
- * mix) to `CanonicalTextAmplifiers` keyed purely by doctrinal field code.
10968
- *
10969
- * - Friendly aliases (with an optional `1`/`2` suffix) map to their canonical
10970
- * code (e.g. `country1` → `AS1`).
10971
- * - Values are trimmed; entries that are empty/whitespace-only after
10972
- * trimming, or not strings, are dropped.
10973
- * - When both a canonical key and its friendly alias are present for the same
10974
- * field + suffix, the **canonical key wins** regardless of insertion order.
10975
- * This holds even for an empty/whitespace canonical value: it suppresses
10976
- * (clears) the alias entry rather than letting the alias survive.
10977
- * - Unknown keys are ignored.
10978
- *
10979
- * `undefined` or an empty input returns a shared frozen empty object.
10980
- */
10981
- function normalizeTextAmplifiers(input) {
10982
- if (!input) return EMPTY_TEXT_AMPLIFIERS;
10983
- const canonicalPresent = /* @__PURE__ */ new Set();
10984
- for (const [key, rawValue] of Object.entries(input)) if (typeof rawValue === "string" && CANONICAL_KEYS.has(key)) canonicalPresent.add(key);
10985
- const result = {};
10986
- for (const [key, rawValue] of Object.entries(input)) {
10987
- if (typeof rawValue !== "string") continue;
10988
- const value = rawValue.trim();
10989
- if (value.length === 0) continue;
10990
- const canonicalKey = canonicalTextAmplifierKey(key);
10991
- if (!canonicalKey) continue;
10992
- if (!CANONICAL_KEYS.has(key) && canonicalPresent.has(canonicalKey)) continue;
10993
- result[canonicalKey] = value;
10994
- }
10995
- return Object.keys(result).length > 0 ? Object.freeze(result) : EMPTY_TEXT_AMPLIFIERS;
10996
- }
10997
- //#endregion
10998
11095
  //#region src/renderControlMeasure.ts
10999
11096
  /**
11000
11097
  * Pure render: `ControlMeasure` → `ControlMeasureRender` (a
@@ -11030,7 +11127,7 @@ function dispatchControlMeasure(cm, opts) {
11030
11127
  if (!validateInputContract(cm.controlPoints, definition.metadata, opts?.validationMode)) return EMPTY_COLLECTION;
11031
11128
  const generator = definition.generator;
11032
11129
  const textAmplifiers = normalizeTextAmplifiers(cm.textAmplifiers);
11033
- return generator(cm.controlPoints, cm.options ?? {}, textAmplifiers);
11130
+ return generator(cm.controlPoints, cm.options ?? {}, textAmplifiers, { amplifierPlacements: cm.amplifierPlacements });
11034
11131
  }
11035
11132
  /**
11036
11133
  * The control-point *input contract*: at least `metadata.minCoordinates` points,
@@ -11047,15 +11144,15 @@ function validateInputContract(points, metadata, mode) {
11047
11144
  }
11048
11145
  /**
11049
11146
  * The `amplifierPlacements` key that repositions this feature, if any: the
11050
- * canonical amplifier field when the generator declared one, an explicit
11051
- * `labelPlacementKey`, or a synthesized `label:<part>:<ordinal>` key for any
11147
+ * explicit `labelPlacementKey`, the canonical amplifier field when the
11148
+ * generator declared one, or a synthesized `label:<part>:<ordinal>` key for any
11052
11149
  * other point label — unless the generator opted out via
11053
11150
  * `labelPlacement: false`.
11054
11151
  */
11055
11152
  function resolveLabelPlacementKey(sourceProps, amplifierField, text, geometryType, part, generatedLabelOrdinal) {
11056
11153
  if (sourceProps.labelPlacement === false) return void 0;
11057
- if (amplifierField) return amplifierField;
11058
11154
  if (typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) return sourceProps.labelPlacementKey;
11155
+ if (amplifierField) return amplifierField;
11059
11156
  if (text !== void 0 && geometryType === "Point") return `label:${part}:${generatedLabelOrdinal}`;
11060
11157
  }
11061
11158
  function normalizeFeature(id, feature, index, generatedLabelOrdinal, measureStyle, graphicsStyle, amplifierPlacements) {
@@ -11141,4 +11238,4 @@ function assertNever(value) {
11141
11238
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
11142
11239
  }
11143
11240
  //#endregion
11144
- export { DEFAULT_GENERIC_C2_LINE_OPTIONS as $, DEFAULT_FORTIFIED_LINE_OPTIONS as A, disruptDrawRule as At, DEFAULT_DELAY_OPTIONS as B, project as Bt, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as C, line23DrawRule as Ct, DEFAULT_SCREEN_OPTIONS as D, attackByFireDrawRule as Dt, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as E, ambushDrawRule as Et, DEFAULT_FIX_OPTIONS as F, pointOnMidpointPerpendicularAxis as Ft, DEFAULT_GENERIC_POLYGON_OPTIONS as G, DEFAULT_TACTICAL_ARROW_OPTIONS as H, EPSILON as Ht, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as I, snapToMidpointPerpendicular as It, DEFAULT_CLASSIC_ARROW_OPTIONS as J, DEFAULT_GENERIC_LINE_OPTIONS as K, DEFAULT_ENCIRCLEMENT_OPTIONS as L, createBaselineFrame as Lt, DEFAULT_HANDOVER_LINE_OPTIONS as M, computeDefaultMidpointPerpendicularPoint as Mt, DEFAULT_PHASE_LINE_OPTIONS as N, createMidpointPerpendicularDrawRule as Nt, DEFAULT_FRONTAL_ATTACK_OPTIONS as O, point12DrawRule as Ot, DEFAULT_FLOT_OPTIONS as P, getMidpointPerpendicularSignedDistance as Pt, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Q, DEFAULT_DISRUPT_OPTIONS as R, calculateMetrics as Rt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as S, line24DrawRule as St, DEFAULT_LANDING_ZONE_OPTIONS as T, line1DrawRule as Tt, DEFAULT_CLEAR_OPTIONS as U, getMetersPerPixel as Ut, DEFAULT_COVER_OPTIONS as V, unproject as Vt, DEFAULT_GENERIC_RECTANGLE_OPTIONS as W, roundToFixed as Wt, DEFAULT_BYPASS_OPTIONS as X, DEFAULT_CANALIZE_OPTIONS as Y, DEFAULT_BREACH_OPTIONS as Z, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as _, DEFAULT_AIRBORNE_ATTACK_OPTIONS as _t, resolveAmplifierPlacement as a, DEFAULT_BATTLE_POSITION_OPTIONS as at, DEFAULT_PICKUP_ZONE_OPTIONS as b, supportByFireDrawRule as bt, CONTROL_MEASURE_METADATA as c, DEFAULT_ANTITANK_WALL_OPTIONS as ct, getControlMeasureMetadataByValue as d, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as dt, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as et, getDefaultOptions as f, DEFAULT_AREA_DEFENSE_OPTIONS as ft, DEFAULT_SUPPORTING_ATTACK_OPTIONS as g, DEFAULT_AMBUSH_OPTIONS as gt, DEFAULT_TURN_OPTIONS as h, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as ht, normalizeTextAmplifiers as i, DEFAULT_BLOCK_OPTIONS as it, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as j, blockDrawRule as jt, DEFAULT_FORTIFIED_AREA_OPTIONS as k, centerRadiusDrawRule as kt, DEFINITIONS as l, DEFAULT_ANTITANK_DITCH_OPTIONS as lt, DEFAULT_TURNING_MOVEMENT_OPTIONS as m, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as mt, TEXT_AMPLIFIER_FIELDS as n, DEFAULT_BOUNDARY_OPTIONS as nt, resolveStyleHints as o, DEFAULT_ATTACK_HELICOPTER_OPTIONS as ot, listControlMeasureMetadata as p, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as pt, DEFAULT_GENERIC_CIRCLE_OPTIONS as q, canonicalTextAmplifierKey as r, DEFAULT_BLOCK_ARROW_OPTIONS as rt, CONTROL_MEASURE_IDS as s, DEFAULT_ATTACK_BY_FIRE_OPTIONS as st, renderControlMeasure as t, DEFAULT_LIGHT_LINE_OPTIONS as tt, getControlMeasureMetadata as u, DEFAULT_ASSEMBLY_AREA_OPTIONS as ut, DEFAULT_STRONG_POINT_OPTIONS as v, rectangleDrawRule as vt, DEFAULT_MAIN_ATTACK_OPTIONS as w, turnDrawRule as wt, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as x, line26DrawRule as xt, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as y, axis1DrawRule as yt, DEFAULT_GUARD_OPTIONS as z, computeInitialWidthPoint as zt };
11241
+ export { DEFAULT_BLOCK_ARROW_OPTIONS as $, DEFAULT_FLOT_OPTIONS as A, disruptDrawRule as At, DEFAULT_GENERIC_RECTANGLE_OPTIONS as B, project as Bt, DEFAULT_SCREEN_OPTIONS as C, line23DrawRule as Ct, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as D, attackByFireDrawRule as Dt, DEFAULT_FORTIFIED_LINE_OPTIONS as E, ambushDrawRule as Et, DEFAULT_GUARD_OPTIONS as F, pointOnMidpointPerpendicularAxis as Ft, DEFAULT_CANALIZE_OPTIONS as G, DEFAULT_GENERIC_LINE_OPTIONS as H, EPSILON as Ht, DEFAULT_DELAY_OPTIONS as I, snapToMidpointPerpendicular as It, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as J, DEFAULT_BYPASS_OPTIONS as K, DEFAULT_COVER_OPTIONS as L, createBaselineFrame as Lt, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as M, computeDefaultMidpointPerpendicularPoint as Mt, DEFAULT_ENCIRCLEMENT_OPTIONS as N, createMidpointPerpendicularDrawRule as Nt, DEFAULT_HANDOVER_LINE_OPTIONS as O, point12DrawRule as Ot, DEFAULT_DISRUPT_OPTIONS as P, getMidpointPerpendicularSignedDistance as Pt, DEFAULT_BOUNDARY_OPTIONS as Q, DEFAULT_TACTICAL_ARROW_OPTIONS as R, calculateMetrics as Rt, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as S, line24DrawRule as St, DEFAULT_FORTIFIED_AREA_OPTIONS as T, line1DrawRule as Tt, DEFAULT_GENERIC_CIRCLE_OPTIONS as U, getMetersPerPixel as Ut, DEFAULT_GENERIC_POLYGON_OPTIONS as V, unproject as Vt, DEFAULT_CLASSIC_ARROW_OPTIONS as W, roundToFixed as Wt, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as X, DEFAULT_GENERIC_C2_LINE_OPTIONS as Y, DEFAULT_LIGHT_LINE_OPTIONS as Z, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as _, DEFAULT_AIRBORNE_ATTACK_OPTIONS as _t, DEFINITIONS as a, DEFAULT_ANTITANK_DITCH_OPTIONS as at, DEFAULT_MAIN_ATTACK_OPTIONS as b, supportByFireDrawRule as bt, getDefaultOptions as c, DEFAULT_AREA_DEFENSE_OPTIONS as ct, DEFAULT_TURN_OPTIONS as d, normalizeTextAmplifiers as dt, DEFAULT_BLOCK_OPTIONS as et, DEFAULT_SUPPORTING_ATTACK_OPTIONS as f, resolveAmplifierPlacement as ft, DEFAULT_PICKUP_ZONE_OPTIONS as g, DEFAULT_AMBUSH_OPTIONS as gt, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as h, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, DEFAULT_ANTITANK_WALL_OPTIONS as it, DEFAULT_FIX_OPTIONS as j, blockDrawRule as jt, DEFAULT_PHASE_LINE_OPTIONS as k, centerRadiusDrawRule as kt, listControlMeasureMetadata as l, TEXT_AMPLIFIER_FIELDS as lt, DEFAULT_STRONG_POINT_OPTIONS as m, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as mt, resolveStyleHints as n, DEFAULT_ATTACK_HELICOPTER_OPTIONS as nt, getControlMeasureMetadata as o, DEFAULT_ASSEMBLY_AREA_OPTIONS as ot, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as p, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as pt, DEFAULT_BREACH_OPTIONS as q, CONTROL_MEASURE_IDS as r, DEFAULT_ATTACK_BY_FIRE_OPTIONS as rt, getControlMeasureMetadataByValue as s, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as st, renderControlMeasure as t, DEFAULT_BATTLE_POSITION_OPTIONS as tt, DEFAULT_TURNING_MOVEMENT_OPTIONS as u, canonicalTextAmplifierKey as ut, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as v, rectangleDrawRule as vt, DEFAULT_FRONTAL_ATTACK_OPTIONS as w, turnDrawRule as wt, DEFAULT_LANDING_ZONE_OPTIONS as x, line26DrawRule as xt, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as y, axis1DrawRule as yt, DEFAULT_CLEAR_OPTIONS as z, computeInitialWidthPoint as zt };