@orbat-mapper/control-measures 0.2.0-alpha.15 → 0.2.0-alpha.17

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.
@@ -1411,6 +1411,13 @@ const AREA_DESIGNATION_AMPLIFIER = {
1411
1411
  description: "Field T — the unit's designation, centered in the area.",
1412
1412
  maxLength: 21
1413
1413
  };
1414
+ const AREA_HOSTILE_AMPLIFIER = {
1415
+ key: "N",
1416
+ label: "Hostile (ENY) marker",
1417
+ description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
1418
+ placeholder: "ENY",
1419
+ maxLength: 3
1420
+ };
1414
1421
  const AREA_TEXT_AMPLIFIERS = [
1415
1422
  {
1416
1423
  key: "T",
@@ -1437,17 +1444,9 @@ const AREA_TEXT_AMPLIFIERS = [
1437
1444
  description: "Field W1 — date-time group the area ceases to be effective.",
1438
1445
  maxLength: 16
1439
1446
  },
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
- }
1447
+ AREA_HOSTILE_AMPLIFIER
1447
1448
  ];
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");
1449
+ const AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS = [AREA_DESIGNATION_AMPLIFIER, AREA_HOSTILE_AMPLIFIER];
1451
1450
  /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
1452
1451
  * for measures whose template omits that row but keeps the W/W1 window and
1453
1452
  * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
@@ -2040,6 +2039,10 @@ function polylineSegments(verts) {
2040
2039
  totalLength
2041
2040
  };
2042
2041
  }
2042
+ /** Resolves an along-line position onto the valid start-to-end range. */
2043
+ function clampLinePosition(value, fallback) {
2044
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2045
+ }
2043
2046
  /**
2044
2047
  * Resolves the point at `fraction` (clamped to `[0, 1]`) of `totalLength`
2045
2048
  * along `segments`, walking cumulative arc length exactly like Boundary's
@@ -2135,7 +2138,7 @@ function labelSizeProps(options) {
2135
2138
  return {};
2136
2139
  }
2137
2140
  /** Pushes a `part: "label"` Point feature at projected `point`. */
2138
- function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField) {
2141
+ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField, labelPlacementKey) {
2139
2142
  out.push({
2140
2143
  type: "Feature",
2141
2144
  properties: {
@@ -2144,6 +2147,7 @@ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierF
2144
2147
  rotation,
2145
2148
  ...textAnchor ? { textAnchor } : {},
2146
2149
  ...amplifierField ? { amplifierField } : {},
2150
+ ...labelPlacementKey ? { labelPlacementKey } : {},
2147
2151
  ...sizeProps
2148
2152
  },
2149
2153
  geometry: {
@@ -2297,10 +2301,6 @@ const LABEL_GAP_CLEARANCE_RATIO = .3;
2297
2301
  function nonNegative$1(value, fallback) {
2298
2302
  return Number.isFinite(value) ? Math.max(0, value) : fallback;
2299
2303
  }
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
2304
  /** Arc-length knockout centered on an along-route text label. */
2305
2305
  function directionLabelGap(totalLength, fraction, text, options) {
2306
2306
  if (!text) return void 0;
@@ -2325,19 +2325,20 @@ function createDirectionOfAttackLine(coordinates, options, textAmplifiers, confi
2325
2325
  const tip = vecAdd(last.start, vecScale(last.along, last.length));
2326
2326
  const length = Math.min(totalLength, totalLength * nonNegative$1(options.arrowheadLengthRatio, DEFAULT_DIRECTION_ARROWHEAD_LENGTH_RATIO));
2327
2327
  const innerHalfWidth = length * nonNegative$1(options.arrowheadWidthRatio, DEFAULT_DIRECTION_ARROWHEAD_WIDTH_RATIO) / 2;
2328
- const base = vecSub(tip, vecScale(last.along, length));
2328
+ const outlineExtension = config.outlineRatio ? length * Math.max(0, config.outlineRatio) : 0;
2329
+ const innerTip = vecSub(tip, vecScale(last.along, outlineExtension));
2330
+ const base = vecSub(innerTip, vecScale(last.along, length));
2329
2331
  const innerLeft = vecAdd(base, vecScale(last.perp, innerHalfWidth));
2330
2332
  const innerRight = vecSub(base, vecScale(last.perp, innerHalfWidth));
2331
2333
  const arrowhead = config.outlineRatio ? (() => {
2332
- const ratio = Math.max(0, config.outlineRatio);
2333
- const outerHalfWidth = innerHalfWidth * (1 + ratio);
2334
+ const outerHalfWidth = innerHalfWidth * (1 + Math.max(0, config.outlineRatio));
2334
2335
  const outerLeft = vecAdd(base, vecScale(last.perp, outerHalfWidth));
2335
2336
  return [
2336
2337
  innerLeft,
2337
- tip,
2338
+ innerTip,
2338
2339
  innerRight,
2339
2340
  vecSub(base, vecScale(last.perp, outerHalfWidth)),
2340
- vecAdd(tip, vecScale(last.along, length * ratio)),
2341
+ tip,
2341
2342
  outerLeft,
2342
2343
  innerLeft
2343
2344
  ];
@@ -2346,9 +2347,14 @@ function createDirectionOfAttackLine(coordinates, options, textAmplifiers, confi
2346
2347
  tip,
2347
2348
  innerRight
2348
2349
  ];
2350
+ const shaftVerts = outlineExtension > 0 ? verts.slice(0, -1) : verts;
2351
+ if (outlineExtension > 0) {
2352
+ while (shaftVerts.length > 1 && vecDot(vecSub(shaftVerts[shaftVerts.length - 1], innerTip), last.along) > 0) shaftVerts.pop();
2353
+ shaftVerts.push(innerTip);
2354
+ }
2349
2355
  const labelPosition = clampLinePosition(options.labelPosition, DEFAULT_DIRECTION_LABEL_POSITION);
2350
2356
  const labelGap = directionLabelGap(totalLength, labelPosition, textAmplifiers.T, options);
2351
- const lines = [...buildGappedLine(verts, labelGap ? [labelGap] : []), arrowhead.map((point) => unproject(point[0], point[1]))];
2357
+ const lines = [...buildGappedLine(shaftVerts, labelGap ? [labelGap] : []), arrowhead.map((point) => unproject(point[0], point[1]))];
2352
2358
  const features = [{
2353
2359
  type: "Feature",
2354
2360
  properties: { part: config.part },
@@ -2782,6 +2788,141 @@ const DIRECTION_OF_SUPPORTING_ATTACK = defineControlMeasure({
2782
2788
  }
2783
2789
  });
2784
2790
  //#endregion
2791
+ //#region src/text-amplifiers.ts
2792
+ /**
2793
+ * The vocabulary of text amplifier fields: doctrinal code, milsymbol-style
2794
+ * friendly alias, and a human-readable label. The single source of truth for
2795
+ * both the runtime catalog and the derived alias→code lookup used by
2796
+ * {@link normalizeTextAmplifiers}.
2797
+ */
2798
+ const TEXT_AMPLIFIER_FIELDS = {
2799
+ C: {
2800
+ alias: "quantity",
2801
+ label: "Quantity"
2802
+ },
2803
+ H: {
2804
+ alias: "additionalInformation",
2805
+ label: "Additional information"
2806
+ },
2807
+ N: {
2808
+ alias: "hostile",
2809
+ label: "Hostile (ENY) marker"
2810
+ },
2811
+ T: {
2812
+ alias: "uniqueDesignation",
2813
+ label: "Unique designation"
2814
+ },
2815
+ V: {
2816
+ alias: "type",
2817
+ label: "Equipment/type"
2818
+ },
2819
+ W: {
2820
+ alias: "dtg",
2821
+ label: "Date-time group / O/O"
2822
+ },
2823
+ X: {
2824
+ alias: "altitudeDepth",
2825
+ label: "Altitude/depth"
2826
+ },
2827
+ Y: {
2828
+ alias: "location",
2829
+ label: "Location"
2830
+ },
2831
+ AM: {
2832
+ alias: "distance",
2833
+ label: "Distance/range/radius"
2834
+ },
2835
+ AN: {
2836
+ alias: "azimuth",
2837
+ label: "Azimuth"
2838
+ },
2839
+ AP: {
2840
+ alias: "targetNumber",
2841
+ label: "Target number"
2842
+ },
2843
+ AS: {
2844
+ alias: "country",
2845
+ label: "Country code"
2846
+ }
2847
+ };
2848
+ /** Derived alias→code lookup, so the vocabulary table stays the single source of truth. */
2849
+ const CODE_BY_ALIAS = Object.fromEntries(Object.entries(TEXT_AMPLIFIER_FIELDS).map(([code, { alias }]) => [alias, code]));
2850
+ const CANONICAL_KEYS = new Set(Object.keys(TEXT_AMPLIFIER_FIELDS).flatMap((code) => [
2851
+ code,
2852
+ `${code}1`,
2853
+ `${code}2`
2854
+ ]));
2855
+ /**
2856
+ * Resolves a raw placement entry to its object form: a bare `Position` is
2857
+ * legacy shorthand for `{ position }`; an object form passes through only the
2858
+ * fields that are actually valid (`position` must be a finite `[lon, lat]`,
2859
+ * `rotation` a finite number); `undefined` resolves to `{}`.
2860
+ */
2861
+ function resolveAmplifierPlacement(value) {
2862
+ if (value === void 0) return {};
2863
+ if (Array.isArray(value)) return isValidPosition(value) ? { position: value } : {};
2864
+ const result = {};
2865
+ if (value.position !== void 0 && isValidPosition(value.position)) result.position = value.position;
2866
+ if (typeof value.rotation === "number" && Number.isFinite(value.rotation)) result.rotation = value.rotation;
2867
+ return result;
2868
+ }
2869
+ const EMPTY_TEXT_AMPLIFIERS = Object.freeze({});
2870
+ /** Splits a key like `"country1"` into its base (`"country"`) and suffix (`"1"`). */
2871
+ function splitSuffix(key) {
2872
+ const last = key.charAt(key.length - 1);
2873
+ if (last === "1" || last === "2") return {
2874
+ base: key.slice(0, -1),
2875
+ suffix: last
2876
+ };
2877
+ return {
2878
+ base: key,
2879
+ suffix: ""
2880
+ };
2881
+ }
2882
+ /**
2883
+ * Resolves any accepted key (canonical or friendly alias, with an optional
2884
+ * `1`/`2` suffix) to its canonical key, or `undefined` if unknown — e.g.
2885
+ * `"T"` → `"T"`, `"uniqueDesignation1"` → `"T1"`, `"bogus"` → `undefined`.
2886
+ */
2887
+ function canonicalTextAmplifierKey(key) {
2888
+ if (CANONICAL_KEYS.has(key)) return key;
2889
+ const { base, suffix } = splitSuffix(key);
2890
+ const code = CODE_BY_ALIAS[base];
2891
+ return code ? `${code}${suffix}` : void 0;
2892
+ }
2893
+ /**
2894
+ * Resolves a `TextAmplifiers` record (canonical keys, friendly aliases, or a
2895
+ * mix) to `CanonicalTextAmplifiers` keyed purely by doctrinal field code.
2896
+ *
2897
+ * - Friendly aliases (with an optional `1`/`2` suffix) map to their canonical
2898
+ * code (e.g. `country1` → `AS1`).
2899
+ * - Values are trimmed; entries that are empty/whitespace-only after
2900
+ * trimming, or not strings, are dropped.
2901
+ * - When both a canonical key and its friendly alias are present for the same
2902
+ * field + suffix, the **canonical key wins** regardless of insertion order.
2903
+ * This holds even for an empty/whitespace canonical value: it suppresses
2904
+ * (clears) the alias entry rather than letting the alias survive.
2905
+ * - Unknown keys are ignored.
2906
+ *
2907
+ * `undefined` or an empty input returns a shared frozen empty object.
2908
+ */
2909
+ function normalizeTextAmplifiers(input) {
2910
+ if (!input) return EMPTY_TEXT_AMPLIFIERS;
2911
+ const canonicalPresent = /* @__PURE__ */ new Set();
2912
+ for (const [key, rawValue] of Object.entries(input)) if (typeof rawValue === "string" && CANONICAL_KEYS.has(key)) canonicalPresent.add(key);
2913
+ const result = {};
2914
+ for (const [key, rawValue] of Object.entries(input)) {
2915
+ if (typeof rawValue !== "string") continue;
2916
+ const value = rawValue.trim();
2917
+ if (value.length === 0) continue;
2918
+ const canonicalKey = canonicalTextAmplifierKey(key);
2919
+ if (!canonicalKey) continue;
2920
+ if (!CANONICAL_KEYS.has(key) && canonicalPresent.has(canonicalKey)) continue;
2921
+ result[canonicalKey] = value;
2922
+ }
2923
+ return Object.keys(result).length > 0 ? Object.freeze(result) : EMPTY_TEXT_AMPLIFIERS;
2924
+ }
2925
+ //#endregion
2785
2926
  //#region src/internal/area-echelon.ts
2786
2927
  /**
2787
2928
  * Twice the ring's signed area (shoelace). Positive when the ring winds
@@ -3049,6 +3190,36 @@ function ringCrossingsAtY(verts, target) {
3049
3190
  totalArc: dist
3050
3191
  };
3051
3192
  }
3193
+ /** Nearest contact point on a closed ring for a dragged marker anchor. */
3194
+ function nearestPointOnRing(verts, target) {
3195
+ let nearest;
3196
+ let totalArc = 0;
3197
+ for (let i = 0; i < verts.length - 1; i++) {
3198
+ const a = verts[i];
3199
+ const b = verts[i + 1];
3200
+ const seg = vecSub(b, a);
3201
+ const length = vecMag(seg);
3202
+ if (length < 1e-6) continue;
3203
+ const along = vecScale(seg, 1 / length);
3204
+ const t = Math.max(0, Math.min(length, vecDot(vecSub(target, a), along)));
3205
+ const distance = vecMag(vecSub(target, vecAdd(a, vecScale(along, t))));
3206
+ if (!nearest || distance < nearest.distance) nearest = {
3207
+ along,
3208
+ arc: totalArc + t,
3209
+ distance
3210
+ };
3211
+ totalArc += length;
3212
+ }
3213
+ return nearest ? {
3214
+ ...nearest,
3215
+ totalArc
3216
+ } : void 0;
3217
+ }
3218
+ function labelHalfExtent(direction, textWidth, textHeight, rotation) {
3219
+ const widthAxis = [Math.cos(rotation), Math.sin(rotation)];
3220
+ const heightAxis = [-widthAxis[1], widthAxis[0]];
3221
+ return .5 * (textWidth * Math.abs(vecDot(direction, widthAxis)) + textHeight * Math.abs(vecDot(direction, heightAxis)));
3222
+ }
3052
3223
  /**
3053
3224
  * Pushes an area's L-15 text amplifier labels — centered name/H/W-W1 rows and
3054
3225
  * west/east "ENY" edge markers — as `part: "label"` Point features (via
@@ -3067,25 +3238,27 @@ function ringCrossingsAtY(verts, target) {
3067
3238
  *
3068
3239
  * When `texts.hostile` is set, the ring's west/east crossings of the
3069
3240
  * horizontal line through the centroid are found ({@link ringCrossingsAtY});
3070
- * the minimum/maximum-x crossings each get an "ENY" label centered exactly on
3071
- * the crossing point (no inset, no `textAnchor`), at the centroid's latitude.
3072
- * Each marker also carves a masking {@link ArcGap} from the boundary at that
3073
- * crossing's arc length, sized as the support function of the label's
3074
- * axis-aligned bounding box along the crossing edge's own direction `d` (a
3075
- * boundary running vertically there needs roughly the label's height; one
3076
- * running horizontally needs roughly its width):
3241
+ * the minimum/maximum-x crossings are the default anchors for the two "ENY"
3242
+ * labels. A persisted `label:N:west` / `label:N:east` placement replaces that
3243
+ * anchor. Each marker carves a masking {@link ArcGap} at the nearest point on
3244
+ * the boundary only while its estimated text box touches the boundary; moving
3245
+ * it away restores the line. The gap follows the label around any edge and is
3246
+ * sized as the support function of its possibly rotated text box along the
3247
+ * contacted edge's own direction `d` (a boundary running vertically there
3248
+ * needs roughly the label's height; one running horizontally needs roughly
3249
+ * its width):
3077
3250
  * `halfExtent = 0.5 * (textWidth·|d.x| + textHeight·|d.y|) + clearance`, where
3078
3251
  * `textHeight = resolveLabelOffsetMeters(options, 1)`,
3079
3252
  * `textWidth = text.length * ENY_CHAR_ASPECT_RATIO * textHeight`, and
3080
3253
  * `clearance = (ENY_CLEARANCE_RATIO + labelPadding) * textHeight` — mirroring
3081
3254
  * {@link placeEchelon}'s glyph-width masking gap (ADR-0023). Fewer than two
3082
3255
  * crossings (e.g. a degenerate ring, or a concave shape whose centroid
3083
- * latitude misses the boundary) skips the marker (and its gap) silently
3084
- * rather than guessing a placement.
3256
+ * latitude misses the boundary) skips the markers silently rather than
3257
+ * guessing their default placements.
3085
3258
  *
3086
3259
  * A ring with fewer than 2 vertices is degenerate — nothing is placed.
3087
3260
  */
3088
- function pushAreaLabels(out, ringVerts, texts, options = {}) {
3261
+ function pushAreaLabels(out, ringVerts, texts, options = {}, amplifierPlacements) {
3089
3262
  if (ringVerts.length < 2) return [];
3090
3263
  const sizeProps = labelSizeProps(options);
3091
3264
  const padding = Math.max(0, options.labelPadding ?? 0);
@@ -3110,15 +3283,26 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
3110
3283
  const { crossings, totalArc } = ringCrossingsAtY(ringVerts, cy);
3111
3284
  if (crossings.length >= 2) {
3112
3285
  const byX = [...crossings].sort((a, b) => a.x - b.x);
3113
- const west = byX[0];
3114
- const east = byX[byX.length - 1];
3115
3286
  const textHeight = resolveLabelOffsetMeters(options, 1);
3116
3287
  const textWidth = texts.hostile.length * ENY_CHAR_ASPECT_RATIO * textHeight;
3117
3288
  const clearance = (ENY_CLEARANCE_RATIO + padding) * textHeight;
3118
- for (const crossing of [west, east]) {
3119
- pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N");
3120
- const halfExtent = .5 * (textWidth * Math.abs(crossing.along[0]) + textHeight * Math.abs(crossing.along[1])) + clearance;
3121
- pushWrappedGap(gaps, crossing.arc - halfExtent, crossing.arc + halfExtent, totalArc);
3289
+ for (const [side, crossing] of [["west", byX[0]], ["east", byX[byX.length - 1]]]) {
3290
+ const labelPlacementKey = `label:N:${side}`;
3291
+ const placement = resolveAmplifierPlacement(amplifierPlacements?.[labelPlacementKey]);
3292
+ const rotation = placement.rotation ?? HORIZONTAL_LABEL_ROTATION;
3293
+ const labelPoint = placement.position ? project(placement.position[0], placement.position[1]) : [crossing.x, cy];
3294
+ pushLabel(out, labelPoint, texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N", labelPlacementKey);
3295
+ const contact = placement.position ? nearestPointOnRing(ringVerts, labelPoint) : {
3296
+ along: crossing.along,
3297
+ arc: crossing.arc,
3298
+ distance: 0,
3299
+ totalArc
3300
+ };
3301
+ if (!contact) continue;
3302
+ const normal = [-contact.along[1], contact.along[0]];
3303
+ if (contact.distance > labelHalfExtent(normal, textWidth, textHeight, rotation)) continue;
3304
+ const tangentExtent = labelHalfExtent(contact.along, textWidth, textHeight, rotation) + clearance;
3305
+ pushWrappedGap(gaps, contact.arc - tangentExtent, contact.arc + tangentExtent, contact.totalArc);
3122
3306
  }
3123
3307
  }
3124
3308
  }
@@ -3159,11 +3343,11 @@ function boundaryFeature(verts, gaps) {
3159
3343
  * echelon-masked boundary, ADR-0023) instead of a closed `Polygon` — the
3160
3344
  * common (no `N`) case is unaffected.
3161
3345
  */
3162
- function labeledAreaFeatures(positions, options, texts) {
3346
+ function labeledAreaFeatures(positions, options, texts, amplifierPlacements) {
3163
3347
  const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$9, ...labelOptions } = options;
3164
3348
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$9);
3165
3349
  const labelFeatures = [];
3166
- const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions))];
3350
+ const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements))];
3167
3351
  features.push(...labelFeatures);
3168
3352
  return {
3169
3353
  type: "FeatureCollection",
@@ -3201,8 +3385,8 @@ const AREA_METADATA = {
3201
3385
  * at its center with an un-prefixed `T` (Field T) plus an `N` (Field N) ENY
3202
3386
  * marker, per {@link labeledAreaFeatures}.
3203
3387
  */
3204
- function createArea(positions, options = {}, textAmplifiers = {}) {
3205
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""));
3388
+ function createArea(positions, options = {}, textAmplifiers = {}, context = {}) {
3389
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""), context.amplifierPlacements);
3206
3390
  }
3207
3391
  const AREA = defineControlMeasure({
3208
3392
  metadata: AREA_METADATA,
@@ -3448,8 +3632,8 @@ const AREA_OF_OPERATIONS_METADATA = {
3448
3632
  * `positions` labeled at its center with "AO "-prefixed `T` (Field T) plus
3449
3633
  * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
3450
3634
  */
3451
- function createAreaOfOperations(positions, options = {}, textAmplifiers = {}) {
3452
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"));
3635
+ function createAreaOfOperations(positions, options = {}, textAmplifiers = {}, context = {}) {
3636
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"), context.amplifierPlacements);
3453
3637
  }
3454
3638
  const AREA_OF_OPERATIONS = defineControlMeasure({
3455
3639
  metadata: AREA_OF_OPERATIONS_METADATA,
@@ -3468,15 +3652,6 @@ const AREA_OF_OPERATIONS = defineControlMeasure({
3468
3652
  }
3469
3653
  });
3470
3654
  //#endregion
3471
- //#region src/generators/cm12-command-control-areas/params.ts
3472
- const SMOOTH_AREA_PARAMS = AREA_SMOOTH_PARAMS;
3473
- const AREA_DESIGNATION_ONLY_AMPLIFIER = {
3474
- key: "T",
3475
- label: "Unique designation",
3476
- description: "Field T — the area's unique designation.",
3477
- maxLength: 21
3478
- };
3479
- //#endregion
3480
3655
  //#region src/generators/cm12-command-control-areas/named-area-of-interest.ts
3481
3656
  /**
3482
3657
  * Default options for the Named area of interest control measure. The
@@ -3496,19 +3671,23 @@ const NAMED_AREA_OF_INTEREST_METADATA = {
3496
3671
  value: "120200",
3497
3672
  minCoordinates: 3,
3498
3673
  geometry: "area",
3499
- geometryTypes: ["Polygon", "Point"],
3674
+ geometryTypes: [
3675
+ "Polygon",
3676
+ "MultiLineString",
3677
+ "Point"
3678
+ ],
3500
3679
  drawRule: "Area1",
3501
3680
  capturesLabelSize: true,
3502
- params: SMOOTH_AREA_PARAMS,
3503
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
3681
+ params: LABELED_AREA_PARAMS,
3682
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
3504
3683
  };
3505
3684
  /**
3506
3685
  * Creates a Named area of interest control measure: a closed area through
3507
- * `positions` labeled at its center with "NAI "-prefixed `T` (Field T) —
3508
- * the sole label row, per {@link labeledAreaFeatures}.
3686
+ * `positions` labeled at its center with "NAI "-prefixed `T` (Field T) plus
3687
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
3509
3688
  */
3510
- function createNamedAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
3511
- return labeledAreaFeatures(positions, options, { name: composeAreaName("NAI", textAmplifiers.T) });
3689
+ function createNamedAreaOfInterest(positions, options = {}, textAmplifiers = {}, context = {}) {
3690
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "NAI"), context.amplifierPlacements);
3512
3691
  }
3513
3692
  const NAMED_AREA_OF_INTEREST = defineControlMeasure({
3514
3693
  metadata: NAMED_AREA_OF_INTEREST_METADATA,
@@ -3546,19 +3725,23 @@ const TARGET_AREA_OF_INTEREST_METADATA = {
3546
3725
  value: "120300",
3547
3726
  minCoordinates: 3,
3548
3727
  geometry: "area",
3549
- geometryTypes: ["Polygon", "Point"],
3728
+ geometryTypes: [
3729
+ "Polygon",
3730
+ "MultiLineString",
3731
+ "Point"
3732
+ ],
3550
3733
  drawRule: "Area1",
3551
3734
  capturesLabelSize: true,
3552
- params: SMOOTH_AREA_PARAMS,
3553
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
3735
+ params: LABELED_AREA_PARAMS,
3736
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
3554
3737
  };
3555
3738
  /**
3556
3739
  * Creates a Target area of interest control measure: a closed area through
3557
- * `positions` labeled at its center with "TAI "-prefixed `T` (Field T) —
3558
- * the sole label row, per {@link labeledAreaFeatures}.
3740
+ * `positions` labeled at its center with "TAI "-prefixed `T` (Field T) plus
3741
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
3559
3742
  */
3560
- function createTargetAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
3561
- return labeledAreaFeatures(positions, options, { name: composeAreaName("TAI", textAmplifiers.T) });
3743
+ function createTargetAreaOfInterest(positions, options = {}, textAmplifiers = {}, context = {}) {
3744
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "TAI"), context.amplifierPlacements);
3562
3745
  }
3563
3746
  const TARGET_AREA_OF_INTEREST = defineControlMeasure({
3564
3747
  metadata: TARGET_AREA_OF_INTEREST_METADATA,
@@ -3612,21 +3795,25 @@ const AIRFIELD_ZONE_METADATA = {
3612
3795
  ],
3613
3796
  drawRule: "Area1",
3614
3797
  capturesLabelSize: true,
3615
- params: [...SMOOTH_AREA_PARAMS, {
3616
- key: "glyphSizeRatio",
3617
- label: "Runway size",
3618
- description: "Size of the runway glyph as a ratio of the area's width.",
3619
- type: "number",
3620
- min: .2,
3621
- max: .9,
3622
- step: .05
3623
- }],
3798
+ params: [
3799
+ ...AREA_SMOOTH_PARAMS,
3800
+ {
3801
+ key: "glyphSizeRatio",
3802
+ label: "Runway size",
3803
+ description: "Size of the runway glyph as a ratio of the area's width.",
3804
+ type: "number",
3805
+ min: .2,
3806
+ max: .9,
3807
+ step: .05
3808
+ },
3809
+ AREA_LABEL_PADDING_PARAM
3810
+ ],
3624
3811
  textAmplifiers: [{
3625
3812
  key: "H",
3626
3813
  label: "Additional information",
3627
3814
  description: "Field H — airfield type, runway length and other information, placed outside the area's east edge.",
3628
3815
  maxLength: 20
3629
- }]
3816
+ }, AREA_HOSTILE_AMPLIFIER]
3630
3817
  };
3631
3818
  /**
3632
3819
  * Creates an Airfield zone control measure: a closed area through
@@ -3637,12 +3824,14 @@ const AIRFIELD_ZONE_METADATA = {
3637
3824
  * @param positions - Area anchor points (≥3); the input contract is enforced
3638
3825
  * at the render seam (ADR-0014).
3639
3826
  * @param options - {@link AirfieldZoneOptions}.
3640
- * @param textAmplifiers - Field H only.
3827
+ * @param textAmplifiers - Field H (placed east of the area) plus an optional
3828
+ * Field N "ENY" hostile marker on the west/east edges.
3641
3829
  */
3642
- function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
3830
+ function createAirfieldZone(positions, options = {}, textAmplifiers = {}, context = {}) {
3643
3831
  const { smooth = DEFAULT_AIRFIELD_ZONE_OPTIONS.smooth, smoothResolution = DEFAULT_AIRFIELD_ZONE_OPTIONS.smoothResolution, glyphSizeRatio = DEFAULT_AIRFIELD_ZONE_OPTIONS.glyphSizeRatio, ...labelOptions } = options;
3644
3832
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$8);
3645
- const features = [boundaryFeature(verts, [])];
3833
+ const enyLabels = [];
3834
+ const features = [boundaryFeature(verts, textAmplifiers.N ? pushAreaLabels(enyLabels, verts, { hostile: textAmplifiers.N }, labelOptions, context.amplifierPlacements) : [])];
3646
3835
  let minX = Infinity;
3647
3836
  let maxX = -Infinity;
3648
3837
  for (const [x] of verts) {
@@ -3674,6 +3863,7 @@ function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
3674
3863
  pushLabel(labelFeatures, [maxX + H_LABEL_OFFSET_RATIO * resolveLabelOffsetMeters(labelOptions, 1), cy], textAmplifiers.H, HORIZONTAL_LABEL_ROTATION, labelSizeProps(labelOptions), "start", "H");
3675
3864
  features.push(...labelFeatures);
3676
3865
  }
3866
+ features.push(...enyLabels);
3677
3867
  return {
3678
3868
  type: "FeatureCollection",
3679
3869
  features
@@ -3717,13 +3907,13 @@ const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
3717
3907
  * before carving the boundary, so either (or both) opens it into a gapped
3718
3908
  * `MultiLineString`; with neither, the boundary is a closed `Polygon`.
3719
3909
  */
3720
- function echelonLabeledAreaFeatures(positions, options, texts) {
3910
+ function echelonLabeledAreaFeatures(positions, options, texts, amplifierPlacements) {
3721
3911
  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;
3722
3912
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
3723
3913
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
3724
3914
  const { strokes, fills, gaps: echelonGaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
3725
3915
  const labelFeatures = [];
3726
- const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions);
3916
+ const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions, amplifierPlacements);
3727
3917
  const features = [boundaryFeature(verts, [...echelonGaps, ...enyGaps])];
3728
3918
  if (strokes.length > 0) features.push({
3729
3919
  type: "Feature",
@@ -3792,8 +3982,8 @@ const BASE_CAMP_METADATA = {
3792
3982
  * amplifier rows, and an optional Field B echelon glyph straddling the
3793
3983
  * boundary, per {@link echelonLabeledAreaFeatures}.
3794
3984
  */
3795
- function createBaseCamp(positions, options = {}, textAmplifiers = {}) {
3796
- return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "BC"));
3985
+ function createBaseCamp(positions, options = {}, textAmplifiers = {}, context = {}) {
3986
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "BC"), context.amplifierPlacements);
3797
3987
  }
3798
3988
  const BASE_CAMP = defineControlMeasure({
3799
3989
  metadata: BASE_CAMP_METADATA,
@@ -3845,8 +4035,8 @@ const GUERRILLA_BASE_METADATA = {
3845
4035
  * `H`/`W`/`W1`/`N` amplifier rows, and an optional Field B echelon glyph
3846
4036
  * straddling the boundary, per {@link echelonLabeledAreaFeatures}.
3847
4037
  */
3848
- function createGuerrillaBase(positions, options = {}, textAmplifiers = {}) {
3849
- return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "GB"));
4038
+ function createGuerrillaBase(positions, options = {}, textAmplifiers = {}, context = {}) {
4039
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "GB"), context.amplifierPlacements);
3850
4040
  }
3851
4041
  const GUERRILLA_BASE = defineControlMeasure({
3852
4042
  metadata: GUERRILLA_BASE_METADATA,
@@ -3902,8 +4092,8 @@ const GENERIC_C2_AREA_METADATA = {
3902
4092
  * through `positions` labeled at its center with the bare `T` (Field T) plus
3903
4093
  * `H`/`W`/`W1`/`N` amplifier rows — no prefix, per {@link labeledAreaFeatures}.
3904
4094
  */
3905
- function createGenericC2Area(positions, options = {}, textAmplifiers = {}) {
3906
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers));
4095
+ function createGenericC2Area(positions, options = {}, textAmplifiers = {}, context = {}) {
4096
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers), context.amplifierPlacements);
3907
4097
  }
3908
4098
  const GENERIC_C2_AREA = defineControlMeasure({
3909
4099
  metadata: GENERIC_C2_AREA_METADATA,
@@ -3952,8 +4142,8 @@ const ASSEMBLY_AREA_METADATA = {
3952
4142
  * `positions` labeled at its center with "AA "-prefixed `T` (Field T) plus
3953
4143
  * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
3954
4144
  */
3955
- function createAssemblyArea(positions, options = {}, textAmplifiers = {}) {
3956
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AA"));
4145
+ function createAssemblyArea(positions, options = {}, textAmplifiers = {}, context = {}) {
4146
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AA"), context.amplifierPlacements);
3957
4147
  }
3958
4148
  const ASSEMBLY_AREA = defineControlMeasure({
3959
4149
  metadata: ASSEMBLY_AREA_METADATA,
@@ -4634,7 +4824,7 @@ const BATTLE_POSITION_METADATA = {
4634
4824
  drawRule: "Area1",
4635
4825
  capturesLabelSize: true,
4636
4826
  params: ECHELON_AREA_PARAMS,
4637
- textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
4827
+ textAmplifiers: AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS
4638
4828
  };
4639
4829
  /**
4640
4830
  * Creates a Battle Position control measure: a closed area through `positions`
@@ -4644,16 +4834,22 @@ const BATTLE_POSITION_METADATA = {
4644
4834
  * @param positions - Area anchor points (≥3); the input contract is enforced at
4645
4835
  * the render seam (ADR-0014).
4646
4836
  * @param options - {@link BattlePositionOptions}.
4647
- * @param textAmplifiers - Field T only, centered in the area (no H/W rows —
4648
- * this measure's template pairs the designation with the Field B glyph,
4649
- * not the plain L-15 boundary template).
4837
+ * @param textAmplifiers - Field T (designation, centered in the area) plus an
4838
+ * optional Field N "ENY" hostile marker at the west/east edges (no H/W rows —
4839
+ * this measure's template pairs the designation with the Field B glyph, not
4840
+ * the plain L-15 boundary template).
4650
4841
  */
4651
- function createBattlePosition(positions, options = {}, textAmplifiers = {}) {
4842
+ function createBattlePosition(positions, options = {}, textAmplifiers = {}, context = {}) {
4652
4843
  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;
4653
4844
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
4654
4845
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6);
4655
4846
  const { strokes, fills, gaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
4656
- const boundaryCoords = buildGappedLine(verts, gaps);
4847
+ const labelFeatures = [];
4848
+ const enyGaps = pushAreaLabels(labelFeatures, verts, {
4849
+ name: textAmplifiers.T,
4850
+ hostile: textAmplifiers.N
4851
+ }, options, context.amplifierPlacements);
4852
+ const boundaryCoords = buildGappedLine(verts, [...gaps, ...enyGaps]);
4657
4853
  const features = [];
4658
4854
  if (boundaryCoords.length > 0) features.push({
4659
4855
  type: "Feature",
@@ -4682,8 +4878,6 @@ function createBattlePosition(positions, options = {}, textAmplifiers = {}) {
4682
4878
  coordinates: fills.map((ring) => [ring])
4683
4879
  }
4684
4880
  });
4685
- const labelFeatures = [];
4686
- pushAreaLabels(labelFeatures, verts, { name: textAmplifiers.T }, options);
4687
4881
  features.push(...labelFeatures);
4688
4882
  return {
4689
4883
  type: "FeatureCollection",
@@ -5047,6 +5241,10 @@ function normalizeSmoothResolution$1(value) {
5047
5241
  //#endregion
5048
5242
  //#region src/generators/cm11-command-control-lines/boundary.ts
5049
5243
  const DEFAULT_SMOOTH_RESOLUTION$4 = 12;
5244
+ function resolveLabelRepetitions(value) {
5245
+ const numeric = Number(value ?? 1);
5246
+ return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
5247
+ }
5050
5248
  /** Default options for the Boundary control measure. */
5051
5249
  const DEFAULT_BOUNDARY_OPTIONS = {
5052
5250
  echelon: "battalion",
@@ -5055,6 +5253,7 @@ const DEFAULT_BOUNDARY_OPTIONS = {
5055
5253
  echelonPadding: .3,
5056
5254
  labelRepetitions: 1,
5057
5255
  labelSpacing: 1,
5256
+ labelPosition: .5,
5058
5257
  labelPadding: .4,
5059
5258
  labelOrientation: "along",
5060
5259
  smooth: false,
@@ -5137,6 +5336,16 @@ const BOUNDARY_METADATA = {
5137
5336
  max: 5,
5138
5337
  step: 1
5139
5338
  },
5339
+ {
5340
+ key: "labelPosition",
5341
+ label: "Label position",
5342
+ description: "Position of the label group along the boundary, from the first point (0) to the last (1)",
5343
+ type: "number",
5344
+ min: 0,
5345
+ max: 1,
5346
+ step: .01,
5347
+ visibleWhen: (opts) => resolveLabelRepetitions(opts.labelRepetitions) === 1
5348
+ },
5140
5349
  {
5141
5350
  key: "labelSpacing",
5142
5351
  label: "Label spacing",
@@ -5145,7 +5354,7 @@ const BOUNDARY_METADATA = {
5145
5354
  min: 0,
5146
5355
  max: 2,
5147
5356
  step: .05,
5148
- visibleWhen: (opts) => Number(opts.labelRepetitions ?? 1) > 1
5357
+ visibleWhen: (opts) => resolveLabelRepetitions(opts.labelRepetitions) > 1
5149
5358
  },
5150
5359
  {
5151
5360
  key: "labelPadding",
@@ -5208,7 +5417,7 @@ const BOUNDARY_METADATA = {
5208
5417
  * unit 1 (left-of-travel side), `T1`/`AS1` label unit 2 (right-of-travel side).
5209
5418
  */
5210
5419
  function createBoundary(positions, options = {}, textAmplifiers = {}) {
5211
- 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;
5420
+ 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;
5212
5421
  const unit1Designator = textAmplifiers.T ?? "";
5213
5422
  const unit1Country = textAmplifiers.AS ?? "";
5214
5423
  const unit2Designator = textAmplifiers.T1 ?? "";
@@ -5216,7 +5425,8 @@ function createBoundary(positions, options = {}, textAmplifiers = {}) {
5216
5425
  let h = echelonSize;
5217
5426
  if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
5218
5427
  h = Math.max(EPSILON, h);
5219
- const reps = Math.max(1, Math.round(labelRepetitions));
5428
+ const reps = resolveLabelRepetitions(labelRepetitions);
5429
+ const singleGroupPosition = clampLinePosition(labelPosition, DEFAULT_BOUNDARY_OPTIONS.labelPosition);
5220
5430
  const spec = resolveEchelon(echelon);
5221
5431
  const labelSize = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0 ? { textSizePixels: echelonSizePixels } : { textSizeMeters: h };
5222
5432
  const rawVerts = positions.map((p) => project(p[0], p[1]));
@@ -5228,7 +5438,7 @@ function createBoundary(positions, options = {}, textAmplifiers = {}) {
5228
5438
  const { segments: segs, totalLength } = polylineSegments(verts);
5229
5439
  if (totalLength >= 1e-6) for (let k = 0; k < reps; k++) {
5230
5440
  const evenFraction = (k + 1) / (reps + 1);
5231
- const fraction = Math.min(1, Math.max(0, .5 + (evenFraction - .5) * labelSpacing));
5441
+ const fraction = Math.min(1, Math.max(0, reps === 1 ? singleGroupPosition : .5 + (evenFraction - .5) * labelSpacing));
5232
5442
  const target = fraction * totalLength;
5233
5443
  const frame = pointAlongPolyline(segs, totalLength, fraction);
5234
5444
  const center = frame.point;
@@ -7663,8 +7873,8 @@ const DROP_ZONE_METADATA = {
7663
7873
  * labeled at its center with "DZ "-prefixed `T` (Field T) plus an `N`
7664
7874
  * (Field N) ENY marker, per {@link labeledAreaFeatures}.
7665
7875
  */
7666
- function createDropZone(positions, options = {}, textAmplifiers = {}) {
7667
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "DZ"));
7876
+ function createDropZone(positions, options = {}, textAmplifiers = {}, context = {}) {
7877
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "DZ"), context.amplifierPlacements);
7668
7878
  }
7669
7879
  const DROP_ZONE = defineControlMeasure({
7670
7880
  metadata: DROP_ZONE_METADATA,
@@ -7778,11 +7988,11 @@ function buildBarbs(perimeter, spacing, barbLength, gaps) {
7778
7988
  * Creates an Encirclement tactical graphic: a complete smooth boundary ring
7779
7989
  * with outward arrowhead barbs resting on it. The boundary is an unfilled
7780
7990
  * Polygon; the barbs are open "V" strokes (a MultiLineString) whose base is the
7781
- * boundary arc itself. When a Field N (ENY) marker is set, it sits ON the
7782
- * boundary and masks it with a knockout gap (mirroring Field B's echelon
7783
- * glyph, ADR-0023) — the boundary becomes a gapped MultiLineString instead of
7784
- * a closed Polygon, and any barb whose center falls inside the gap is
7785
- * suppressed; the no-`N` case is unaffected.
7991
+ * boundary arc itself. When a Field N (ENY) marker is set, it masks the
7992
+ * boundary with a knockout gap wherever its text box touches the ring
7993
+ * (mirroring Field B's echelon glyph, ADR-0023) — the boundary becomes a
7994
+ * gapped MultiLineString instead of a closed Polygon, and any barb whose
7995
+ * center falls inside the gap is suppressed; the no-`N` case is unaffected.
7786
7996
  *
7787
7997
  * @param positions - Area anchor points (>=3); the input contract is enforced
7788
7998
  * at the render seam (ADR-0014).
@@ -7790,12 +8000,12 @@ function buildBarbs(perimeter, spacing, barbLength, gaps) {
7790
8000
  * @param textAmplifiers - Field N only — rendered as "ENY" markers at the
7791
8001
  * ring's west and east edges, per the doctrinal hostile example.
7792
8002
  */
7793
- function createEncirclement(positions, options = {}, textAmplifiers = {}) {
8003
+ function createEncirclement(positions, options = {}, textAmplifiers = {}, context = {}) {
7794
8004
  const { barbSpacingRatio = DEFAULT_BARB_SPACING_RATIO, barbLengthRatio = DEFAULT_BARB_LENGTH_RATIO, smooth = DEFAULT_ENCIRCLEMENT_OPTIONS.smooth, smoothResolution = DEFAULT_ENCIRCLEMENT_OPTIONS.smoothResolution } = options;
7795
8005
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$2);
7796
8006
  const perimeter = buildAreaPerimeter(verts);
7797
8007
  const labelFeatures = [];
7798
- const gaps = pushAreaLabels(labelFeatures, verts, { hostile: textAmplifiers.N }, options);
8008
+ const gaps = pushAreaLabels(labelFeatures, verts, { hostile: textAmplifiers.N }, options, context.amplifierPlacements);
7799
8009
  const radius = meanRadius(verts);
7800
8010
  const barbLength = radius * Math.max(0, barbLengthRatio);
7801
8011
  const barbs = buildBarbs(perimeter, radius * Math.max(0, barbSpacingRatio), barbLength, gaps);
@@ -7870,8 +8080,8 @@ const EXTRACTION_ZONE_METADATA = {
7870
8080
  * `positions` labeled at its center with "EZ "-prefixed `T` (Field T) plus
7871
8081
  * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
7872
8082
  */
7873
- function createExtractionZone(positions, options = {}, textAmplifiers = {}) {
7874
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "EZ"));
8083
+ function createExtractionZone(positions, options = {}, textAmplifiers = {}, context = {}) {
8084
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "EZ"), context.amplifierPlacements);
7875
8085
  }
7876
8086
  const EXTRACTION_ZONE = defineControlMeasure({
7877
8087
  metadata: EXTRACTION_ZONE_METADATA,
@@ -8771,7 +8981,7 @@ const FORTIFIED_AREA_METADATA = {
8771
8981
  drawRule: "Area1",
8772
8982
  capturesLabelSize: true,
8773
8983
  params: [...FORTIFIED_AREA_PARAMS, AREA_LABEL_PADDING_PARAM],
8774
- textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_HOSTILE
8984
+ textAmplifiers: AREA_TEXT_AMPLIFIERS
8775
8985
  };
8776
8986
  /**
8777
8987
  * Creates a Fortified Area tactical graphic.
@@ -8785,8 +8995,8 @@ const FORTIFIED_AREA_METADATA = {
8785
8995
  * @param options - Configuration options for the graphic
8786
8996
  * @param textAmplifiers - Field T (bare, no prefix — "TANGO", not "FA TANGO")
8787
8997
  * plus Field H/W/W1, centered on the pre-teeth boundary ring (see
8788
- * `boundaryInput` below); no Field N — Fortified Area's L-15 template
8789
- * carries no ENY marker.
8998
+ * `boundaryInput` below), and an optional Field N "ENY" hostile marker on
8999
+ * that ring's west/east edges (placed but not boundary-masked — see below).
8790
9000
  * @returns A GeoJSON FeatureCollection containing the castellated Polygon
8791
9001
  * plus any label Points
8792
9002
  */
@@ -8816,7 +9026,8 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
8816
9026
  name: textAmplifiers.T,
8817
9027
  additionalInformation: textAmplifiers.H,
8818
9028
  dtgStart: textAmplifiers.W,
8819
- dtgEnd: textAmplifiers.W1
9029
+ dtgEnd: textAmplifiers.W1,
9030
+ hostile: textAmplifiers.N
8820
9031
  }, options);
8821
9032
  features.push(...labelFeatures);
8822
9033
  return {
@@ -9237,8 +9448,8 @@ const JOINT_TACTICAL_ACTION_AREA_METADATA = {
9237
9448
  * (Field T) plus `H`/`W`/`W1`/`N` amplifier rows, per
9238
9449
  * {@link labeledAreaFeatures}.
9239
9450
  */
9240
- function createJointTacticalActionArea(positions, options = {}, textAmplifiers = {}) {
9241
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "JTAA -", ""));
9451
+ function createJointTacticalActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
9452
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "JTAA -", ""), context.amplifierPlacements);
9242
9453
  }
9243
9454
  const JOINT_TACTICAL_ACTION_AREA = defineControlMeasure({
9244
9455
  metadata: JOINT_TACTICAL_ACTION_AREA_METADATA,
@@ -9291,8 +9502,8 @@ const LANDING_ZONE_METADATA = {
9291
9502
  * labeled at its center with "LZ "-prefixed `T` (Field T) plus `H`/`W`/`W1`/`N`
9292
9503
  * amplifier rows, per {@link labeledAreaFeatures}.
9293
9504
  */
9294
- function createLandingZone(positions, options = {}, textAmplifiers = {}) {
9295
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "LZ"));
9505
+ function createLandingZone(positions, options = {}, textAmplifiers = {}, context = {}) {
9506
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "LZ"), context.amplifierPlacements);
9296
9507
  }
9297
9508
  const LANDING_ZONE = defineControlMeasure({
9298
9509
  metadata: LANDING_ZONE_METADATA,
@@ -9811,8 +10022,8 @@ const PICKUP_ZONE_METADATA = {
9811
10022
  * labeled at its center with "PZ "-prefixed `T` (Field T) plus `H`/`W`/`W1`/`N`
9812
10023
  * amplifier rows, per {@link labeledAreaFeatures}.
9813
10024
  */
9814
- function createPickupZone(positions, options = {}, textAmplifiers = {}) {
9815
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "PZ"));
10025
+ function createPickupZone(positions, options = {}, textAmplifiers = {}, context = {}) {
10026
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "PZ"), context.amplifierPlacements);
9816
10027
  }
9817
10028
  const PICKUP_ZONE = defineControlMeasure({
9818
10029
  metadata: PICKUP_ZONE_METADATA,
@@ -9846,19 +10057,23 @@ const ASSAULT_POSITION_METADATA = {
9846
10057
  value: "151500",
9847
10058
  minCoordinates: 3,
9848
10059
  geometry: "area",
9849
- geometryTypes: ["Polygon", "Point"],
10060
+ geometryTypes: [
10061
+ "Polygon",
10062
+ "MultiLineString",
10063
+ "Point"
10064
+ ],
9850
10065
  drawRule: "Area1",
9851
10066
  capturesLabelSize: true,
9852
- params: SMOOTH_AREA_PARAMS,
9853
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10067
+ params: LABELED_AREA_PARAMS,
10068
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9854
10069
  };
9855
10070
  /**
9856
10071
  * Creates an Assault position control measure: a closed area through
9857
- * `positions` labeled at its center with "ASLT "-prefixed `T` (Field T) —
9858
- * the sole label row, per {@link labeledAreaFeatures}.
10072
+ * `positions` labeled at its center with "ASLT "-prefixed `T` (Field T) plus
10073
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9859
10074
  */
9860
- function createAssaultPosition(positions, options = {}, textAmplifiers = {}) {
9861
- return labeledAreaFeatures(positions, options, { name: composeAreaName("ASLT", textAmplifiers.T) });
10075
+ function createAssaultPosition(positions, options = {}, textAmplifiers = {}, context = {}) {
10076
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "ASLT"), context.amplifierPlacements);
9862
10077
  }
9863
10078
  const ASSAULT_POSITION = defineControlMeasure({
9864
10079
  metadata: ASSAULT_POSITION_METADATA,
@@ -9892,19 +10107,23 @@ const ATTACK_POSITION_METADATA = {
9892
10107
  value: "151600",
9893
10108
  minCoordinates: 3,
9894
10109
  geometry: "area",
9895
- geometryTypes: ["Polygon", "Point"],
10110
+ geometryTypes: [
10111
+ "Polygon",
10112
+ "MultiLineString",
10113
+ "Point"
10114
+ ],
9896
10115
  drawRule: "Area1",
9897
10116
  capturesLabelSize: true,
9898
- params: SMOOTH_AREA_PARAMS,
9899
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10117
+ params: LABELED_AREA_PARAMS,
10118
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9900
10119
  };
9901
10120
  /**
9902
10121
  * Creates an Attack position control measure: a closed area through
9903
- * `positions` labeled at its center with "ATK "-prefixed `T` (Field T) —
9904
- * the sole label row, per {@link labeledAreaFeatures}.
10122
+ * `positions` labeled at its center with "ATK "-prefixed `T` (Field T) plus
10123
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9905
10124
  */
9906
- function createAttackPosition(positions, options = {}, textAmplifiers = {}) {
9907
- return labeledAreaFeatures(positions, options, { name: composeAreaName("ATK", textAmplifiers.T) });
10125
+ function createAttackPosition(positions, options = {}, textAmplifiers = {}, context = {}) {
10126
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "ATK"), context.amplifierPlacements);
9908
10127
  }
9909
10128
  const ATTACK_POSITION = defineControlMeasure({
9910
10129
  metadata: ATTACK_POSITION_METADATA,
@@ -9938,19 +10157,23 @@ const OBJECTIVE_AREA_METADATA = {
9938
10157
  value: "151700",
9939
10158
  minCoordinates: 3,
9940
10159
  geometry: "area",
9941
- geometryTypes: ["Polygon", "Point"],
10160
+ geometryTypes: [
10161
+ "Polygon",
10162
+ "MultiLineString",
10163
+ "Point"
10164
+ ],
9942
10165
  drawRule: "Area1",
9943
10166
  capturesLabelSize: true,
9944
- params: SMOOTH_AREA_PARAMS,
9945
- textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
10167
+ params: LABELED_AREA_PARAMS,
10168
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
9946
10169
  };
9947
10170
  /**
9948
10171
  * Creates an Objective area control measure: a closed area through
9949
- * `positions` labeled at its center with "OBJ "-prefixed `T` (Field T) —
9950
- * the sole label row, per {@link labeledAreaFeatures}.
10172
+ * `positions` labeled at its center with "OBJ "-prefixed `T` (Field T) plus
10173
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
9951
10174
  */
9952
- function createObjectiveArea(positions, options = {}, textAmplifiers = {}) {
9953
- return labeledAreaFeatures(positions, options, { name: composeAreaName("OBJ", textAmplifiers.T) });
10175
+ function createObjectiveArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10176
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "OBJ"), context.amplifierPlacements);
9954
10177
  }
9955
10178
  const OBJECTIVE_AREA = defineControlMeasure({
9956
10179
  metadata: OBJECTIVE_AREA_METADATA,
@@ -10073,7 +10296,7 @@ const STRONG_POINT_METADATA = {
10073
10296
  drawRule: "Area1",
10074
10297
  capturesLabelSize: true,
10075
10298
  params: ECHELON_AREA_PARAMS,
10076
- textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
10299
+ textAmplifiers: AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS
10077
10300
  };
10078
10301
  /**
10079
10302
  * Outward perimeter tics: a stroke every `h` meters along the ring, each `h`
@@ -10108,17 +10331,24 @@ function buildStrongPointTics(perimeter, gaps, h) {
10108
10331
  * @param positions - Area anchor points (>=3); the input contract is enforced
10109
10332
  * at the render seam (ADR-0014).
10110
10333
  * @param options - {@link StrongPointOptions}.
10111
- * @param textAmplifiers - Field T only, centered in the area, same as Battle
10112
- * Position.
10334
+ * @param textAmplifiers - Field T (designation, centered in the area) plus an
10335
+ * optional Field N "ENY" hostile marker at the west/east edges, same as
10336
+ * Battle Position.
10113
10337
  */
10114
- function createStrongPoint(positions, options = {}, textAmplifiers = {}) {
10338
+ function createStrongPoint(positions, options = {}, textAmplifiers = {}, context = {}) {
10115
10339
  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;
10116
10340
  const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
10117
10341
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION);
10118
10342
  const perimeter = buildAreaPerimeter(verts);
10119
10343
  const { strokes, fills, gaps } = placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, echelonAnchor);
10120
- const boundaryCoords = buildGappedLine(verts, gaps);
10121
- const tics = buildStrongPointTics(perimeter, gaps, h);
10344
+ const labelFeatures = [];
10345
+ const enyGaps = pushAreaLabels(labelFeatures, verts, {
10346
+ name: textAmplifiers.T,
10347
+ hostile: textAmplifiers.N
10348
+ }, options, context.amplifierPlacements);
10349
+ const allGaps = [...gaps, ...enyGaps];
10350
+ const boundaryCoords = buildGappedLine(verts, allGaps);
10351
+ const tics = buildStrongPointTics(perimeter, allGaps, h);
10122
10352
  const features = [];
10123
10353
  if (boundaryCoords.length > 0) features.push({
10124
10354
  type: "Feature",
@@ -10155,8 +10385,6 @@ function createStrongPoint(positions, options = {}, textAmplifiers = {}) {
10155
10385
  coordinates: fills.map((ring) => [ring])
10156
10386
  }
10157
10387
  });
10158
- const labelFeatures = [];
10159
- pushAreaLabels(labelFeatures, verts, { name: textAmplifiers.T }, options);
10160
10388
  features.push(...labelFeatures);
10161
10389
  return {
10162
10390
  type: "FeatureCollection",
@@ -10216,8 +10444,8 @@ const SUBMARINE_ACTION_AREA_METADATA = {
10216
10444
  * `positions` labeled at its center with "SAA -"-prefixed `T` (Field T) plus
10217
10445
  * `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
10218
10446
  */
10219
- function createSubmarineActionArea(positions, options = {}, textAmplifiers = {}) {
10220
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SAA -", ""));
10447
+ function createSubmarineActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10448
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SAA -", ""), context.amplifierPlacements);
10221
10449
  }
10222
10450
  const SUBMARINE_ACTION_AREA = defineControlMeasure({
10223
10451
  metadata: SUBMARINE_ACTION_AREA_METADATA,
@@ -10271,8 +10499,8 @@ const SUBMARINE_GENERATED_ACTION_AREA_METADATA = {
10271
10499
  * through `positions` labeled at its center with "SGSA -"-prefixed `T`
10272
10500
  * (Field T) plus `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
10273
10501
  */
10274
- function createSubmarineGeneratedActionArea(positions, options = {}, textAmplifiers = {}) {
10275
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SGSA -", ""));
10502
+ function createSubmarineGeneratedActionArea(positions, options = {}, textAmplifiers = {}, context = {}) {
10503
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SGSA -", ""), context.amplifierPlacements);
10276
10504
  }
10277
10505
  const SUBMARINE_GENERATED_ACTION_AREA = defineControlMeasure({
10278
10506
  metadata: SUBMARINE_GENERATED_ACTION_AREA_METADATA,
@@ -10854,141 +11082,6 @@ function applyLayer(target, layer) {
10854
11082
  }
10855
11083
  }
10856
11084
  //#endregion
10857
- //#region src/text-amplifiers.ts
10858
- /**
10859
- * The vocabulary of text amplifier fields: doctrinal code, milsymbol-style
10860
- * friendly alias, and a human-readable label. The single source of truth for
10861
- * both the runtime catalog and the derived alias→code lookup used by
10862
- * {@link normalizeTextAmplifiers}.
10863
- */
10864
- const TEXT_AMPLIFIER_FIELDS = {
10865
- C: {
10866
- alias: "quantity",
10867
- label: "Quantity"
10868
- },
10869
- H: {
10870
- alias: "additionalInformation",
10871
- label: "Additional information"
10872
- },
10873
- N: {
10874
- alias: "hostile",
10875
- label: "Hostile (ENY) marker"
10876
- },
10877
- T: {
10878
- alias: "uniqueDesignation",
10879
- label: "Unique designation"
10880
- },
10881
- V: {
10882
- alias: "type",
10883
- label: "Equipment/type"
10884
- },
10885
- W: {
10886
- alias: "dtg",
10887
- label: "Date-time group / O/O"
10888
- },
10889
- X: {
10890
- alias: "altitudeDepth",
10891
- label: "Altitude/depth"
10892
- },
10893
- Y: {
10894
- alias: "location",
10895
- label: "Location"
10896
- },
10897
- AM: {
10898
- alias: "distance",
10899
- label: "Distance/range/radius"
10900
- },
10901
- AN: {
10902
- alias: "azimuth",
10903
- label: "Azimuth"
10904
- },
10905
- AP: {
10906
- alias: "targetNumber",
10907
- label: "Target number"
10908
- },
10909
- AS: {
10910
- alias: "country",
10911
- label: "Country code"
10912
- }
10913
- };
10914
- /** Derived alias→code lookup, so the vocabulary table stays the single source of truth. */
10915
- const CODE_BY_ALIAS = Object.fromEntries(Object.entries(TEXT_AMPLIFIER_FIELDS).map(([code, { alias }]) => [alias, code]));
10916
- const CANONICAL_KEYS = new Set(Object.keys(TEXT_AMPLIFIER_FIELDS).flatMap((code) => [
10917
- code,
10918
- `${code}1`,
10919
- `${code}2`
10920
- ]));
10921
- /**
10922
- * Resolves a raw placement entry to its object form: a bare `Position` is
10923
- * legacy shorthand for `{ position }`; an object form passes through only the
10924
- * fields that are actually valid (`position` must be a finite `[lon, lat]`,
10925
- * `rotation` a finite number); `undefined` resolves to `{}`.
10926
- */
10927
- function resolveAmplifierPlacement(value) {
10928
- if (value === void 0) return {};
10929
- if (Array.isArray(value)) return isValidPosition(value) ? { position: value } : {};
10930
- const result = {};
10931
- if (value.position !== void 0 && isValidPosition(value.position)) result.position = value.position;
10932
- if (typeof value.rotation === "number" && Number.isFinite(value.rotation)) result.rotation = value.rotation;
10933
- return result;
10934
- }
10935
- const EMPTY_TEXT_AMPLIFIERS = Object.freeze({});
10936
- /** Splits a key like `"country1"` into its base (`"country"`) and suffix (`"1"`). */
10937
- function splitSuffix(key) {
10938
- const last = key.charAt(key.length - 1);
10939
- if (last === "1" || last === "2") return {
10940
- base: key.slice(0, -1),
10941
- suffix: last
10942
- };
10943
- return {
10944
- base: key,
10945
- suffix: ""
10946
- };
10947
- }
10948
- /**
10949
- * Resolves any accepted key (canonical or friendly alias, with an optional
10950
- * `1`/`2` suffix) to its canonical key, or `undefined` if unknown — e.g.
10951
- * `"T"` → `"T"`, `"uniqueDesignation1"` → `"T1"`, `"bogus"` → `undefined`.
10952
- */
10953
- function canonicalTextAmplifierKey(key) {
10954
- if (CANONICAL_KEYS.has(key)) return key;
10955
- const { base, suffix } = splitSuffix(key);
10956
- const code = CODE_BY_ALIAS[base];
10957
- return code ? `${code}${suffix}` : void 0;
10958
- }
10959
- /**
10960
- * Resolves a `TextAmplifiers` record (canonical keys, friendly aliases, or a
10961
- * mix) to `CanonicalTextAmplifiers` keyed purely by doctrinal field code.
10962
- *
10963
- * - Friendly aliases (with an optional `1`/`2` suffix) map to their canonical
10964
- * code (e.g. `country1` → `AS1`).
10965
- * - Values are trimmed; entries that are empty/whitespace-only after
10966
- * trimming, or not strings, are dropped.
10967
- * - When both a canonical key and its friendly alias are present for the same
10968
- * field + suffix, the **canonical key wins** regardless of insertion order.
10969
- * This holds even for an empty/whitespace canonical value: it suppresses
10970
- * (clears) the alias entry rather than letting the alias survive.
10971
- * - Unknown keys are ignored.
10972
- *
10973
- * `undefined` or an empty input returns a shared frozen empty object.
10974
- */
10975
- function normalizeTextAmplifiers(input) {
10976
- if (!input) return EMPTY_TEXT_AMPLIFIERS;
10977
- const canonicalPresent = /* @__PURE__ */ new Set();
10978
- for (const [key, rawValue] of Object.entries(input)) if (typeof rawValue === "string" && CANONICAL_KEYS.has(key)) canonicalPresent.add(key);
10979
- const result = {};
10980
- for (const [key, rawValue] of Object.entries(input)) {
10981
- if (typeof rawValue !== "string") continue;
10982
- const value = rawValue.trim();
10983
- if (value.length === 0) continue;
10984
- const canonicalKey = canonicalTextAmplifierKey(key);
10985
- if (!canonicalKey) continue;
10986
- if (!CANONICAL_KEYS.has(key) && canonicalPresent.has(canonicalKey)) continue;
10987
- result[canonicalKey] = value;
10988
- }
10989
- return Object.keys(result).length > 0 ? Object.freeze(result) : EMPTY_TEXT_AMPLIFIERS;
10990
- }
10991
- //#endregion
10992
11085
  //#region src/renderControlMeasure.ts
10993
11086
  /**
10994
11087
  * Pure render: `ControlMeasure` → `ControlMeasureRender` (a
@@ -11024,7 +11117,7 @@ function dispatchControlMeasure(cm, opts) {
11024
11117
  if (!validateInputContract(cm.controlPoints, definition.metadata, opts?.validationMode)) return EMPTY_COLLECTION;
11025
11118
  const generator = definition.generator;
11026
11119
  const textAmplifiers = normalizeTextAmplifiers(cm.textAmplifiers);
11027
- return generator(cm.controlPoints, cm.options ?? {}, textAmplifiers);
11120
+ return generator(cm.controlPoints, cm.options ?? {}, textAmplifiers, { amplifierPlacements: cm.amplifierPlacements });
11028
11121
  }
11029
11122
  /**
11030
11123
  * The control-point *input contract*: at least `metadata.minCoordinates` points,
@@ -11041,15 +11134,15 @@ function validateInputContract(points, metadata, mode) {
11041
11134
  }
11042
11135
  /**
11043
11136
  * The `amplifierPlacements` key that repositions this feature, if any: the
11044
- * canonical amplifier field when the generator declared one, an explicit
11045
- * `labelPlacementKey`, or a synthesized `label:<part>:<ordinal>` key for any
11137
+ * explicit `labelPlacementKey`, the canonical amplifier field when the
11138
+ * generator declared one, or a synthesized `label:<part>:<ordinal>` key for any
11046
11139
  * other point label — unless the generator opted out via
11047
11140
  * `labelPlacement: false`.
11048
11141
  */
11049
11142
  function resolveLabelPlacementKey(sourceProps, amplifierField, text, geometryType, part, generatedLabelOrdinal) {
11050
11143
  if (sourceProps.labelPlacement === false) return void 0;
11051
- if (amplifierField) return amplifierField;
11052
11144
  if (typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) return sourceProps.labelPlacementKey;
11145
+ if (amplifierField) return amplifierField;
11053
11146
  if (text !== void 0 && geometryType === "Point") return `label:${part}:${generatedLabelOrdinal}`;
11054
11147
  }
11055
11148
  function normalizeFeature(id, feature, index, generatedLabelOrdinal, measureStyle, graphicsStyle, amplifierPlacements) {
@@ -11135,4 +11228,4 @@ function assertNever(value) {
11135
11228
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
11136
11229
  }
11137
11230
  //#endregion
11138
- 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 };
11231
+ 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 };