@orbat-mapper/control-measures 0.2.0-alpha.10 → 0.2.0-alpha.12

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.
Files changed (59) hide show
  1. package/dist/{index-D3uCYKe3.d.mts → index-CsJcNxFQ.d.mts} +335 -69
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.mjs +1 -1
  4. package/dist/preview/index.d.mts +65 -10
  5. package/dist/preview/index.mjs +129 -18
  6. package/dist/{renderControlMeasure-BblcXXLp.mjs → renderControlMeasure-DHQO01PA.mjs} +1531 -615
  7. package/media/airborne-attack.svg +1 -1
  8. package/media/ambush.svg +1 -1
  9. package/media/antitank-ditch-completed.svg +1 -1
  10. package/media/antitank-ditch-under-construction.svg +1 -1
  11. package/media/antitank-wall.svg +1 -1
  12. package/media/area-of-operations.svg +1 -0
  13. package/media/assembly-area.svg +1 -0
  14. package/media/attack-by-fire.svg +1 -1
  15. package/media/attack-helicopter.svg +1 -1
  16. package/media/battle-handover-line.svg +1 -0
  17. package/media/battle-position.svg +1 -1
  18. package/media/block-arrow.svg +1 -1
  19. package/media/block-mission-task.svg +1 -1
  20. package/media/block.svg +1 -1
  21. package/media/boundary.svg +1 -1
  22. package/media/breach.svg +1 -1
  23. package/media/bypass.svg +1 -1
  24. package/media/canalize.svg +1 -1
  25. package/media/circle.svg +1 -1
  26. package/media/classic-arrow.svg +1 -1
  27. package/media/clear.svg +1 -1
  28. package/media/delay.svg +1 -1
  29. package/media/disrupt.svg +1 -1
  30. package/media/encirclement.svg +1 -1
  31. package/media/engineer-work-line.svg +1 -0
  32. package/media/final-protective-fire-left.svg +1 -1
  33. package/media/final-protective-fire-right.svg +1 -1
  34. package/media/fix.svg +1 -1
  35. package/media/flot.svg +1 -1
  36. package/media/fortified-area.svg +1 -1
  37. package/media/fortified-line.svg +1 -1
  38. package/media/generic-c2-line.svg +1 -0
  39. package/media/handover-line.svg +1 -0
  40. package/media/isolate.svg +1 -1
  41. package/media/joint-tactical-action-area.svg +1 -0
  42. package/media/landing-zone.svg +1 -0
  43. package/media/light-line.svg +1 -0
  44. package/media/line.svg +1 -1
  45. package/media/main-attack.svg +1 -1
  46. package/media/obstacle-bypass-difficult.svg +1 -1
  47. package/media/obstacle-bypass-easy.svg +1 -1
  48. package/media/obstacle-bypass-impossible.svg +1 -1
  49. package/media/phase-line.svg +1 -0
  50. package/media/pickup-zone.svg +1 -0
  51. package/media/polygon.svg +1 -1
  52. package/media/principal-direction-of-fire.svg +1 -1
  53. package/media/rectangle.svg +1 -1
  54. package/media/search-area.svg +1 -1
  55. package/media/strong-point.svg +1 -1
  56. package/media/support-by-fire.svg +1 -1
  57. package/media/supporting-attack.svg +1 -1
  58. package/media/turn.svg +1 -1
  59. package/package.json +1 -1
@@ -963,7 +963,231 @@ const rectangleDrawRule = {
963
963
  }
964
964
  };
965
965
  //#endregion
966
+ //#region src/internal/echelon.ts
967
+ /**
968
+ * APP-6 echelon (Field B) glyph vocabulary, shared by the control measures that
969
+ * stamp a unit-size amplifier onto their geometry (Boundary, Battle Position).
970
+ *
971
+ * A compact port of the APP-6 echelon designs, modeled on milsymbol but
972
+ * expressed as parametric geometry so it bakes into GeoJSON and strokes
973
+ * monocolor (ADR-0023). Every glyph is built from three primitives — vertical
974
+ * bars, crosses, and dots — plus the team/crew ring and command `++`.
975
+ */
976
+ const ECHELONS = {
977
+ team: {
978
+ kind: "ring",
979
+ count: 1,
980
+ label: "Team/Crew"
981
+ },
982
+ crew: {
983
+ kind: "ring",
984
+ count: 1
985
+ },
986
+ squad: {
987
+ kind: "dots",
988
+ count: 1,
989
+ label: "Squad"
990
+ },
991
+ section: {
992
+ kind: "dots",
993
+ count: 2,
994
+ label: "Section"
995
+ },
996
+ platoon: {
997
+ kind: "dots",
998
+ count: 3,
999
+ label: "Platoon"
1000
+ },
1001
+ company: {
1002
+ kind: "bars",
1003
+ count: 1,
1004
+ label: "Company"
1005
+ },
1006
+ battalion: {
1007
+ kind: "bars",
1008
+ count: 2,
1009
+ label: "Battalion"
1010
+ },
1011
+ regiment: {
1012
+ kind: "bars",
1013
+ count: 3,
1014
+ label: "Regiment"
1015
+ },
1016
+ brigade: {
1017
+ kind: "crosses",
1018
+ count: 1,
1019
+ label: "Brigade"
1020
+ },
1021
+ division: {
1022
+ kind: "crosses",
1023
+ count: 2,
1024
+ label: "Division"
1025
+ },
1026
+ corps: {
1027
+ kind: "crosses",
1028
+ count: 3,
1029
+ label: "Corps"
1030
+ },
1031
+ army: {
1032
+ kind: "crosses",
1033
+ count: 4,
1034
+ label: "Army"
1035
+ },
1036
+ "army-group": {
1037
+ kind: "crosses",
1038
+ count: 5,
1039
+ label: "Army Group"
1040
+ },
1041
+ region: {
1042
+ kind: "crosses",
1043
+ count: 6,
1044
+ label: "Region"
1045
+ },
1046
+ command: {
1047
+ kind: "command",
1048
+ count: 2,
1049
+ label: "Command"
1050
+ }
1051
+ };
1052
+ /** Symbol-form aliases (`"XX"`, `"II"`, `"•••"`, …) → canonical echelon key. */
1053
+ const ECHELON_ALIASES = {
1054
+ ø: "team",
1055
+ "•": "squad",
1056
+ "••": "section",
1057
+ "•••": "platoon",
1058
+ i: "company",
1059
+ ii: "battalion",
1060
+ iii: "regiment",
1061
+ x: "brigade",
1062
+ xx: "division",
1063
+ xxx: "corps",
1064
+ xxxx: "army",
1065
+ xxxxx: "army-group",
1066
+ xxxxxx: "region",
1067
+ "++": "command"
1068
+ };
1069
+ /** Enum options for the params panel, derived from {@link ECHELONS}. */
1070
+ const ECHELON_PARAM_OPTIONS = [{
1071
+ label: "None",
1072
+ value: "none"
1073
+ }, ...Object.entries(ECHELONS).filter(([, spec]) => spec.label !== void 0).map(([value, spec]) => ({
1074
+ label: spec.label,
1075
+ value
1076
+ }))];
1077
+ function resolveEchelon(echelon) {
1078
+ const key = echelon.trim().toLowerCase();
1079
+ if (key.length === 0) return void 0;
1080
+ const alias = ECHELON_ALIASES[key];
1081
+ return ECHELONS[key] ?? (alias ? ECHELONS[alias] : void 0);
1082
+ }
1083
+ /**
1084
+ * Builds the echelon glyph centered at `center`, with its baseline along
1085
+ * `along` and `perp` (both unit vectors), `h` meters tall. Returns the glyph's
1086
+ * `strokes` (open polylines), `fills` (closed rings drawn as solid shapes —
1087
+ * the squad/section/platoon dots), and its intrinsic width along the line; the
1088
+ * caller adds the configurable side padding to derive the line gap.
1089
+ */
1090
+ function buildEchelonGlyph(center, along, perp, h, spec) {
1091
+ const P = (a, p) => {
1092
+ const q = vecAdd(vecAdd(center, vecScale(along, a)), vecScale(perp, p));
1093
+ return unproject(q[0], q[1]);
1094
+ };
1095
+ const ring = (a, r) => {
1096
+ const pts = [];
1097
+ const steps = 16;
1098
+ for (let i = 0; i <= steps; i++) {
1099
+ const t = 2 * Math.PI * i / steps;
1100
+ pts.push(P(a + r * Math.cos(t), r * Math.sin(t)));
1101
+ }
1102
+ return pts;
1103
+ };
1104
+ const strokes = [];
1105
+ const fills = [];
1106
+ const half = h / 2;
1107
+ switch (spec.kind) {
1108
+ case "bars": {
1109
+ const spacing = h * .8;
1110
+ const total = (spec.count - 1) * spacing;
1111
+ for (let j = 0; j < spec.count; j++) {
1112
+ const a = -total / 2 + j * spacing;
1113
+ strokes.push([P(a, -half), P(a, half)]);
1114
+ }
1115
+ return {
1116
+ strokes,
1117
+ fills,
1118
+ width: total
1119
+ };
1120
+ }
1121
+ case "crosses": {
1122
+ const w = h;
1123
+ const spacing = h * 1.4;
1124
+ const total = (spec.count - 1) * spacing;
1125
+ for (let j = 0; j < spec.count; j++) {
1126
+ const a0 = -total / 2 + j * spacing;
1127
+ strokes.push([P(a0 - w / 2, -half), P(a0 + w / 2, half)]);
1128
+ strokes.push([P(a0 - w / 2, half), P(a0 + w / 2, -half)]);
1129
+ }
1130
+ return {
1131
+ strokes,
1132
+ fills,
1133
+ width: total + w
1134
+ };
1135
+ }
1136
+ case "dots": {
1137
+ const r = h * .3;
1138
+ const spacing = h * 1.2;
1139
+ const total = (spec.count - 1) * spacing;
1140
+ for (let j = 0; j < spec.count; j++) fills.push(ring(-total / 2 + j * spacing, r));
1141
+ return {
1142
+ strokes,
1143
+ fills,
1144
+ width: total + 2 * r
1145
+ };
1146
+ }
1147
+ case "ring": {
1148
+ const r = h * .6;
1149
+ strokes.push(ring(0, r));
1150
+ strokes.push([P(-h * .8, -h * .4), P(h * .8, h * .4)]);
1151
+ return {
1152
+ strokes,
1153
+ fills,
1154
+ width: h * 1.6
1155
+ };
1156
+ }
1157
+ case "command": {
1158
+ const arm = h * .5;
1159
+ const spacing = h * 1.4;
1160
+ const total = (spec.count - 1) * spacing;
1161
+ for (let j = 0; j < spec.count; j++) {
1162
+ const a0 = -total / 2 + j * spacing;
1163
+ strokes.push([P(a0 - arm, 0), P(a0 + arm, 0)]);
1164
+ strokes.push([P(a0, -half), P(a0, half)]);
1165
+ }
1166
+ return {
1167
+ strokes,
1168
+ fills,
1169
+ width: total + 2 * arm
1170
+ };
1171
+ }
1172
+ }
1173
+ }
1174
+ //#endregion
966
1175
  //#region src/generators/cm15-maneuver-areas/params.ts
1176
+ const AREA_SMOOTH_PARAMS = [{
1177
+ key: "smooth",
1178
+ label: "Smooth",
1179
+ description: "Round the area's corners by curving the boundary through the control points.",
1180
+ type: "boolean"
1181
+ }, {
1182
+ key: "smoothResolution",
1183
+ label: "Smooth resolution",
1184
+ description: "Number of samples per segment when smooth mode is enabled.",
1185
+ type: "number",
1186
+ min: 2,
1187
+ max: 64,
1188
+ step: 1,
1189
+ visibleWhen: (opts) => Boolean(opts.smooth)
1190
+ }];
967
1191
  const ATTACK_SHAFT_PARAMS = [
968
1192
  {
969
1193
  key: "shaftWidthRatio",
@@ -1075,25 +1299,7 @@ const ENCIRCLEMENT_PARAMS = [
1075
1299
  step: .01
1076
1300
  }
1077
1301
  ];
1078
- const FORTIFIED_AREA_PARAMS = [
1079
- {
1080
- key: "smooth",
1081
- label: "Smooth",
1082
- description: "Round the area's corners by curving the boundary through the control points.",
1083
- type: "boolean"
1084
- },
1085
- {
1086
- key: "smoothResolution",
1087
- label: "Smooth resolution",
1088
- description: "Number of samples per segment when smooth mode is enabled.",
1089
- type: "number",
1090
- min: 2,
1091
- max: 64,
1092
- step: 1,
1093
- visibleWhen: (opts) => Boolean(opts.smooth)
1094
- },
1095
- ...FORTIFIED_AREA_SIZE_PARAMS
1096
- ];
1302
+ const FORTIFIED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, ...FORTIFIED_AREA_SIZE_PARAMS];
1097
1303
  const AREA_LABEL_PADDING_PARAM = {
1098
1304
  key: "labelPadding",
1099
1305
  label: "Label padding",
@@ -1103,22 +1309,53 @@ const AREA_LABEL_PADDING_PARAM = {
1103
1309
  max: 2,
1104
1310
  step: .05
1105
1311
  };
1106
- const LABELED_AREA_PARAMS = [
1312
+ const LABELED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, AREA_LABEL_PADDING_PARAM];
1313
+ const ECHELON_AREA_PARAMS = [
1314
+ ...AREA_SMOOTH_PARAMS,
1107
1315
  {
1108
- key: "smooth",
1109
- label: "Smooth",
1110
- description: "Round the area's corners by curving the boundary through the control points.",
1111
- type: "boolean"
1316
+ key: "echelon",
1317
+ label: "Echelon",
1318
+ description: "Field B unit-size amplifier, drawn on the boundary.",
1319
+ type: "enum",
1320
+ options: ECHELON_PARAM_OPTIONS
1112
1321
  },
1113
1322
  {
1114
- key: "smoothResolution",
1115
- label: "Smooth resolution",
1116
- description: "Number of samples per segment when smooth mode is enabled.",
1323
+ key: "echelonSizePixels",
1324
+ label: "Echelon size",
1325
+ description: "Echelon glyph height in screen pixels.",
1117
1326
  type: "number",
1118
- min: 2,
1119
- max: 64,
1327
+ min: 8,
1328
+ max: 48,
1120
1329
  step: 1,
1121
- visibleWhen: (opts) => Boolean(opts.smooth)
1330
+ unit: "px"
1331
+ },
1332
+ {
1333
+ key: "echelonSize",
1334
+ label: "Echelon size",
1335
+ description: "Echelon glyph height in meters.",
1336
+ type: "number",
1337
+ min: 50,
1338
+ max: 2e4,
1339
+ step: 50,
1340
+ unit: "m"
1341
+ },
1342
+ {
1343
+ key: "echelonPadding",
1344
+ label: "Echelon padding",
1345
+ description: "Gap on each side of the echelon, as a ratio of its height.",
1346
+ type: "number",
1347
+ min: 0,
1348
+ max: 2,
1349
+ step: .05
1350
+ },
1351
+ {
1352
+ key: "echelonPosition",
1353
+ label: "Echelon position",
1354
+ description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
1355
+ type: "number",
1356
+ min: -.5,
1357
+ max: .5,
1358
+ step: .01
1122
1359
  },
1123
1360
  AREA_LABEL_PADDING_PARAM
1124
1361
  ];
@@ -1165,6 +1402,16 @@ const AREA_TEXT_AMPLIFIERS = [
1165
1402
  /** {@link AREA_TEXT_AMPLIFIERS} without the N (hostile) field, for measures
1166
1403
  * whose template carries no ENY marker (Fortified Area). */
1167
1404
  const AREA_TEXT_AMPLIFIERS_NO_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "N");
1405
+ /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
1406
+ * for measures whose template omits that row but keeps the W/W1 window and
1407
+ * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
1408
+ * Area). */
1409
+ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "H");
1410
+ /** {@link AREA_TEXT_AMPLIFIERS} reduced to just the designation (Field T) and
1411
+ * hostile marker (Field N), for measures whose template carries no
1412
+ * additional-information or date-time-group rows (Area, Drop Zone,
1413
+ * Extraction Zone). */
1414
+ const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
1168
1415
  //#endregion
1169
1416
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
1170
1417
  const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
@@ -1415,215 +1662,6 @@ const AMBUSH = defineControlMeasure({
1415
1662
  rule: ambushDrawRule
1416
1663
  });
1417
1664
  //#endregion
1418
- //#region src/internal/echelon.ts
1419
- /**
1420
- * APP-6 echelon (Field B) glyph vocabulary, shared by the control measures that
1421
- * stamp a unit-size amplifier onto their geometry (Boundary, Battle Position).
1422
- *
1423
- * A compact port of the APP-6 echelon designs, modeled on milsymbol but
1424
- * expressed as parametric geometry so it bakes into GeoJSON and strokes
1425
- * monocolor (ADR-0023). Every glyph is built from three primitives — vertical
1426
- * bars, crosses, and dots — plus the team/crew ring and command `++`.
1427
- */
1428
- const ECHELONS = {
1429
- team: {
1430
- kind: "ring",
1431
- count: 1,
1432
- label: "Team/Crew"
1433
- },
1434
- crew: {
1435
- kind: "ring",
1436
- count: 1
1437
- },
1438
- squad: {
1439
- kind: "dots",
1440
- count: 1,
1441
- label: "Squad"
1442
- },
1443
- section: {
1444
- kind: "dots",
1445
- count: 2,
1446
- label: "Section"
1447
- },
1448
- platoon: {
1449
- kind: "dots",
1450
- count: 3,
1451
- label: "Platoon"
1452
- },
1453
- company: {
1454
- kind: "bars",
1455
- count: 1,
1456
- label: "Company"
1457
- },
1458
- battalion: {
1459
- kind: "bars",
1460
- count: 2,
1461
- label: "Battalion"
1462
- },
1463
- regiment: {
1464
- kind: "bars",
1465
- count: 3,
1466
- label: "Regiment"
1467
- },
1468
- brigade: {
1469
- kind: "crosses",
1470
- count: 1,
1471
- label: "Brigade"
1472
- },
1473
- division: {
1474
- kind: "crosses",
1475
- count: 2,
1476
- label: "Division"
1477
- },
1478
- corps: {
1479
- kind: "crosses",
1480
- count: 3,
1481
- label: "Corps"
1482
- },
1483
- army: {
1484
- kind: "crosses",
1485
- count: 4,
1486
- label: "Army"
1487
- },
1488
- "army-group": {
1489
- kind: "crosses",
1490
- count: 5,
1491
- label: "Army Group"
1492
- },
1493
- region: {
1494
- kind: "crosses",
1495
- count: 6,
1496
- label: "Region"
1497
- },
1498
- command: {
1499
- kind: "command",
1500
- count: 2,
1501
- label: "Command"
1502
- }
1503
- };
1504
- /** Symbol-form aliases (`"XX"`, `"II"`, `"•••"`, …) → canonical echelon key. */
1505
- const ECHELON_ALIASES = {
1506
- ø: "team",
1507
- "•": "squad",
1508
- "••": "section",
1509
- "•••": "platoon",
1510
- i: "company",
1511
- ii: "battalion",
1512
- iii: "regiment",
1513
- x: "brigade",
1514
- xx: "division",
1515
- xxx: "corps",
1516
- xxxx: "army",
1517
- xxxxx: "army-group",
1518
- xxxxxx: "region",
1519
- "++": "command"
1520
- };
1521
- /** Enum options for the params panel, derived from {@link ECHELONS}. */
1522
- const ECHELON_PARAM_OPTIONS = [{
1523
- label: "None",
1524
- value: "none"
1525
- }, ...Object.entries(ECHELONS).filter(([, spec]) => spec.label !== void 0).map(([value, spec]) => ({
1526
- label: spec.label,
1527
- value
1528
- }))];
1529
- function resolveEchelon(echelon) {
1530
- const key = echelon.trim().toLowerCase();
1531
- if (key.length === 0) return void 0;
1532
- const alias = ECHELON_ALIASES[key];
1533
- return ECHELONS[key] ?? (alias ? ECHELONS[alias] : void 0);
1534
- }
1535
- /**
1536
- * Builds the echelon glyph centered at `center`, with its baseline along
1537
- * `along` and `perp` (both unit vectors), `h` meters tall. Returns the glyph's
1538
- * `strokes` (open polylines), `fills` (closed rings drawn as solid shapes —
1539
- * the squad/section/platoon dots), and its intrinsic width along the line; the
1540
- * caller adds the configurable side padding to derive the line gap.
1541
- */
1542
- function buildEchelonGlyph(center, along, perp, h, spec) {
1543
- const P = (a, p) => {
1544
- const q = vecAdd(vecAdd(center, vecScale(along, a)), vecScale(perp, p));
1545
- return unproject(q[0], q[1]);
1546
- };
1547
- const ring = (a, r) => {
1548
- const pts = [];
1549
- const steps = 16;
1550
- for (let i = 0; i <= steps; i++) {
1551
- const t = 2 * Math.PI * i / steps;
1552
- pts.push(P(a + r * Math.cos(t), r * Math.sin(t)));
1553
- }
1554
- return pts;
1555
- };
1556
- const strokes = [];
1557
- const fills = [];
1558
- const half = h / 2;
1559
- switch (spec.kind) {
1560
- case "bars": {
1561
- const spacing = h * .8;
1562
- const total = (spec.count - 1) * spacing;
1563
- for (let j = 0; j < spec.count; j++) {
1564
- const a = -total / 2 + j * spacing;
1565
- strokes.push([P(a, -half), P(a, half)]);
1566
- }
1567
- return {
1568
- strokes,
1569
- fills,
1570
- width: total
1571
- };
1572
- }
1573
- case "crosses": {
1574
- const w = h;
1575
- const spacing = h * 1.4;
1576
- const total = (spec.count - 1) * spacing;
1577
- for (let j = 0; j < spec.count; j++) {
1578
- const a0 = -total / 2 + j * spacing;
1579
- strokes.push([P(a0 - w / 2, -half), P(a0 + w / 2, half)]);
1580
- strokes.push([P(a0 - w / 2, half), P(a0 + w / 2, -half)]);
1581
- }
1582
- return {
1583
- strokes,
1584
- fills,
1585
- width: total + w
1586
- };
1587
- }
1588
- case "dots": {
1589
- const r = h * .3;
1590
- const spacing = h * 1.2;
1591
- const total = (spec.count - 1) * spacing;
1592
- for (let j = 0; j < spec.count; j++) fills.push(ring(-total / 2 + j * spacing, r));
1593
- return {
1594
- strokes,
1595
- fills,
1596
- width: total + 2 * r
1597
- };
1598
- }
1599
- case "ring": {
1600
- const r = h * .6;
1601
- strokes.push(ring(0, r));
1602
- strokes.push([P(-h * .8, -h * .4), P(h * .8, h * .4)]);
1603
- return {
1604
- strokes,
1605
- fills,
1606
- width: h * 1.6
1607
- };
1608
- }
1609
- case "command": {
1610
- const arm = h * .5;
1611
- const spacing = h * 1.4;
1612
- const total = (spec.count - 1) * spacing;
1613
- for (let j = 0; j < spec.count; j++) {
1614
- const a0 = -total / 2 + j * spacing;
1615
- strokes.push([P(a0 - arm, 0), P(a0 + arm, 0)]);
1616
- strokes.push([P(a0, -half), P(a0, half)]);
1617
- }
1618
- return {
1619
- strokes,
1620
- fills,
1621
- width: total + 2 * arm
1622
- };
1623
- }
1624
- }
1625
- }
1626
- //#endregion
1627
1665
  //#region src/internal/gapped-line.ts
1628
1666
  /**
1629
1667
  * Pushes the arc-length gap `[g0, g1]` onto `gaps`, normalizing it onto
@@ -1752,13 +1790,81 @@ function arrowAxis(coordinates) {
1752
1790
  };
1753
1791
  }
1754
1792
  /**
1755
- * Clamps and rounds a smooth-resolution option (samples per segment) onto
1756
- * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1757
- * for non-finite input. The default varies per measure, so it's passed in.
1793
+ * Clamps and rounds a smooth-resolution option (samples per segment) onto
1794
+ * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1795
+ * for non-finite input. The default varies per measure, so it's passed in.
1796
+ */
1797
+ function normalizeSmoothResolution$2(value, fallback) {
1798
+ if (!Number.isFinite(value)) return fallback;
1799
+ return Math.min(64, Math.max(2, Math.round(value)));
1800
+ }
1801
+ /**
1802
+ * Shared param descriptors for {@link SmoothLineOptions}, modeled on
1803
+ * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
1804
+ * their own params should spread these at the top of the array.
1805
+ */
1806
+ const SMOOTH_LINE_PARAMS = [{
1807
+ key: "smooth",
1808
+ label: "Smooth",
1809
+ description: "Round the line's corners by curving the path through the control points.",
1810
+ type: "boolean"
1811
+ }, {
1812
+ key: "smoothResolution",
1813
+ label: "Smooth resolution",
1814
+ description: "Number of samples per segment when smooth mode is enabled.",
1815
+ type: "number",
1816
+ min: 2,
1817
+ max: 64,
1818
+ step: 1,
1819
+ visibleWhen: (opts) => Boolean(opts.smooth)
1820
+ }];
1821
+ /**
1822
+ * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
1823
+ * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
1824
+ * is set, else returns `verts` unchanged.
1825
+ */
1826
+ function smoothLineVerts(verts, options, defaultResolution) {
1827
+ return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1828
+ }
1829
+ /**
1830
+ * Resamples a path into equal arc-length segments.
1831
+ *
1832
+ * This is useful for a repeated motif (such as a fortified tooth or FLOT
1833
+ * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1834
+ * parameter, not distance, so using them directly as motif boundaries causes
1835
+ * the motifs to bunch up where the curve's samples are closer together.
1758
1836
  */
1759
- function normalizeSmoothResolution$2(value, fallback) {
1760
- if (!Number.isFinite(value)) return fallback;
1761
- return Math.min(64, Math.max(2, Math.round(value)));
1837
+ function evenlySpacePath(points, targetSegmentLength) {
1838
+ if (points.length < 2) return points;
1839
+ const segmentLengths = [];
1840
+ let totalLength = 0;
1841
+ for (let i = 0; i < points.length - 1; i++) {
1842
+ const length = vecMag(vecSub(points[i + 1], points[i]));
1843
+ segmentLengths.push(length);
1844
+ totalLength += length;
1845
+ }
1846
+ if (totalLength < 1e-6) return [points[0]];
1847
+ const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1848
+ const segmentLength = totalLength / segmentCount;
1849
+ const out = [points[0]];
1850
+ let sourceIndex = 0;
1851
+ let sourceStart = points[0];
1852
+ let consumed = 0;
1853
+ for (let segment = 1; segment < segmentCount; segment++) {
1854
+ const target = segment * segmentLength;
1855
+ while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1856
+ consumed += segmentLengths[sourceIndex];
1857
+ sourceIndex++;
1858
+ sourceStart = points[sourceIndex];
1859
+ }
1860
+ const length = segmentLengths[sourceIndex];
1861
+ if (length < 1e-6) continue;
1862
+ const t = (target - consumed) / length;
1863
+ const sourceEnd = points[sourceIndex + 1];
1864
+ out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
1865
+ }
1866
+ out.push(points[points.length - 1]);
1867
+ return out;
1762
1868
  }
1763
1869
  /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1764
1870
  function catmullRomAt(p0, p1, p2, p3, t) {
@@ -1934,6 +2040,31 @@ function placeEchelon(perimeter, h, echelon, echelonPadding, echelonPosition, ec
1934
2040
  gaps
1935
2041
  };
1936
2042
  }
2043
+ /**
2044
+ * Factory for the `freezeOrientation` hook (ADR-0023) shared by every
2045
+ * echelon-carrying closed-area measure (Battle Position, Strong Point, Base
2046
+ * Camp, Guerrilla Base): pins the echelon anchor to its current bottom-edge
2047
+ * fraction so a subsequent rigid rotation of `controlPoints` (e.g. the
2048
+ * transform box's rotate grip) does not re-derive a different "bottom edge"
2049
+ * against the rotated geometry. `defaults` supplies the measure's own
2050
+ * `echelon`/`smooth`/`smoothResolution` defaults, since each measure's
2051
+ * `DEFAULT_*_OPTIONS` differ (e.g. Battle Position's `echelon: "battalion"`
2052
+ * vs. Base Camp/Guerrilla Base's `echelon: "none"`).
2053
+ *
2054
+ * The returned hook returns `undefined` — nothing to freeze — when:
2055
+ * - `echelonAnchor` is already set (previously frozen or user-authored), or
2056
+ * - the echelon resolves to none (nothing visible to pin), or
2057
+ * - the ring is degenerate (no segments, or a near-zero perimeter).
2058
+ */
2059
+ function makeFreezeEchelonAnchor(defaults) {
2060
+ return function freezeEchelonAnchor(controlPoints, options) {
2061
+ if (options.echelonAnchor !== void 0) return void 0;
2062
+ if (!resolveEchelon(options.echelon ?? defaults.echelon)) return void 0;
2063
+ const perimeter = buildAreaPerimeter(buildClosedRing(controlPoints, options.smooth ?? defaults.smooth, options.smoothResolution ?? defaults.smoothResolution, defaults.smoothResolution));
2064
+ if (perimeter.segs.length === 0 || perimeter.totalLength < 1e-6) return void 0;
2065
+ return { echelonAnchor: perimeter.anchorTarget / perimeter.totalLength };
2066
+ };
2067
+ }
1937
2068
  //#endregion
1938
2069
  //#region src/internal/angle-utils.ts
1939
2070
  /**
@@ -1976,13 +2107,13 @@ function labelRotationAlong(dir) {
1976
2107
  */
1977
2108
  function pushUnitLabels(out, frame, units) {
1978
2109
  const { center, perp, offset, rotation, labelSize } = frame;
1979
- for (const { side, designator, country, textAnchor } of units) {
2110
+ for (const { side, designator, country, textAnchor, amplifierField } of units) {
1980
2111
  let text;
1981
2112
  if (designator.length > 0 && country.length > 0) text = `${designator} (${country})`;
1982
2113
  else if (designator.length > 0) text = designator;
1983
2114
  else if (country.length > 0) text = `(${country})`;
1984
2115
  else continue;
1985
- pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor);
2116
+ pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor, amplifierField);
1986
2117
  }
1987
2118
  }
1988
2119
  /**
@@ -2095,19 +2226,20 @@ function endTangents(verts) {
2095
2226
  };
2096
2227
  }
2097
2228
  /**
2098
- * Resolves {@link LabelSizeOptions} into the `textSize*` properties adapters
2099
- * expect on a label feature, omitting any key whose input was not supplied.
2229
+ * Resolves {@link LabelSizeOptions} into the single text-size property the
2230
+ * label feature carries (ADR-0028): `textSizePixels` when screen-anchored
2231
+ * (`labelSizePixels` present — pixels win over a stray `labelSize`),
2232
+ * `textSizeMeters` when ground-anchored (`labelSize` alone), or neither, in
2233
+ * which case adapters render their engine default, screen-fixed.
2100
2234
  */
2101
2235
  function labelSizeProps(options) {
2102
- const { labelSizePixels, labelSizeZoom, labelSizeResolution } = options;
2103
- const props = {};
2104
- if (labelSizePixels !== void 0) props.textSizePixels = labelSizePixels;
2105
- if (labelSizeZoom !== void 0) props.textSizeZoom = labelSizeZoom;
2106
- if (labelSizeResolution !== void 0) props.textSizeResolution = labelSizeResolution;
2107
- return props;
2236
+ const { labelSize, labelSizePixels } = options;
2237
+ if (labelSizePixels !== void 0) return { textSizePixels: labelSizePixels };
2238
+ if (labelSize !== void 0) return { textSizeMeters: labelSize };
2239
+ return {};
2108
2240
  }
2109
2241
  /** Pushes a `part: "label"` Point feature at projected `point`. */
2110
- function pushLabel(out, point, text, rotation, sizeProps, textAnchor) {
2242
+ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField) {
2111
2243
  out.push({
2112
2244
  type: "Feature",
2113
2245
  properties: {
@@ -2115,6 +2247,7 @@ function pushLabel(out, point, text, rotation, sizeProps, textAnchor) {
2115
2247
  text,
2116
2248
  rotation,
2117
2249
  ...textAnchor ? { textAnchor } : {},
2250
+ ...amplifierField ? { amplifierField } : {},
2118
2251
  ...sizeProps
2119
2252
  },
2120
2253
  geometry: {
@@ -2123,20 +2256,21 @@ function pushLabel(out, point, text, rotation, sizeProps, textAnchor) {
2123
2256
  }
2124
2257
  });
2125
2258
  }
2126
- /** Ratio of resolved label height (`labelSizePixels × labelSizeResolution`) used as end-label clearance. */
2259
+ /** Ratio of resolved label height (`labelSize`, or `labelSizePixels × metersPerPixel`) used as end-label clearance. */
2127
2260
  const END_LABEL_CLEARANCE_RATIO = .3;
2128
2261
  /** Ratio of resolved label height used to inset "above" end labels from the tip. */
2129
2262
  const ABOVE_LABEL_INSET_RATIO = .3;
2130
2263
  /** Ratio of resolved label height used as the "above" end labels' perpendicular offset. */
2131
2264
  const ABOVE_LABEL_OFFSET_RATIO = .6;
2132
2265
  function textHeight(options) {
2133
- const { labelSizePixels, labelSizeResolution } = options;
2134
- if (labelSizePixels !== void 0 && labelSizeResolution !== void 0) return labelSizePixels * labelSizeResolution;
2266
+ const { labelSize, labelSizePixels, metersPerPixel } = options;
2267
+ if (labelSizePixels !== void 0) return metersPerPixel !== void 0 && metersPerPixel > 0 ? labelSizePixels * metersPerPixel : void 0;
2268
+ return labelSize;
2135
2269
  }
2136
2270
  /**
2137
- * Ground-anchored label offset in meters: `ratio` × the label's resolved height
2138
- * (`labelSizePixels × labelSizeResolution`), or `fallbackMeters` when no live
2139
- * size was captured (e.g. no map resolution yet). The fallback is small
2271
+ * Label offset in meters: `ratio` × the label's resolved height (`labelSize`
2272
+ * meters, or `labelSizePixels × metersPerPixel` when screen-anchored), or
2273
+ * `fallbackMeters` when no size resolves (e.g. no map resolution yet). The fallback is small
2140
2274
  * relative to typical control measure spans — the same order of magnitude as
2141
2275
  * Boundary's own smallest label offsets — so a fixed label still reads as "just
2142
2276
  * clear of the line" rather than crowding it or floating away. Callers supply
@@ -2230,16 +2364,20 @@ function pushEndLabelsAbove(out, verts, textForEnd, options = {}) {
2230
2364
  * as a complete FeatureCollection.
2231
2365
  */
2232
2366
  function fixedLabelLineFeatures(positions, options, textForEnd) {
2233
- const { part, placement = "beyond", phaseLineName, includePrefix = true, ...sizeOptions } = options;
2367
+ const { part, placement = "beyond", phaseLineName, includePrefix = true, smooth, smoothResolution, ...sizeOptions } = options;
2368
+ const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
2369
+ smooth,
2370
+ smoothResolution
2371
+ }, 12);
2372
+ const lineCoordinates = smooth ? verts.map((v) => unproject(v[0], v[1])) : positions;
2234
2373
  const features = [{
2235
2374
  type: "Feature",
2236
2375
  properties: { part },
2237
2376
  geometry: {
2238
2377
  type: "LineString",
2239
- coordinates: positions
2378
+ coordinates: lineCoordinates
2240
2379
  }
2241
2380
  }];
2242
- const verts = positions.map((p) => project(p[0], p[1]));
2243
2381
  const labelFeatures = [];
2244
2382
  if (placement === "above") pushEndLabelsAbove(labelFeatures, verts, textForEnd, sizeOptions);
2245
2383
  else pushEndLabels(labelFeatures, verts, textForEnd, sizeOptions);
@@ -2254,7 +2392,9 @@ function fixedLabelLineFeatures(positions, options, textForEnd) {
2254
2392
  //#endregion
2255
2393
  //#region src/internal/area-labels.ts
2256
2394
  /** Stored label rotation for horizontal (screen-upright) area text — adapters
2257
- * render feature rotation as `π − rotation`, so `π` renders as `0`. */
2395
+ * render feature rotation as `π − rotation`, so `π` renders as `0`. Exported
2396
+ * for reuse by measures that place a horizontal label outside the area's
2397
+ * boundary (e.g. Airfield Zone's Field H). */
2258
2398
  const HORIZONTAL_LABEL_ROTATION = Math.PI;
2259
2399
  /** Row pitch, as a ratio of the resolved label height, between stacked
2260
2400
  * name/H/W-W1 rows (see {@link pushAreaLabels}). */
@@ -2268,7 +2408,7 @@ const ENY_CLEARANCE_RATIO = .3;
2268
2408
  * for sizing the boundary knockout gap — no live text-measurement is
2269
2409
  * available at generator time. */
2270
2410
  const ENY_CHAR_ASPECT_RATIO = .6;
2271
- const DEFAULT_SMOOTH_RESOLUTION$7 = 16;
2411
+ const DEFAULT_SMOOTH_RESOLUTION$9 = 16;
2272
2412
  /**
2273
2413
  * Composes an area's main label text from a `prefix` (e.g. `"AO"`) and a
2274
2414
  * `designation` (e.g. `"GREEN"`), mirroring Phase Line's "PL" precedent: a
@@ -2305,7 +2445,8 @@ function composeAreaLabelTexts(textAmplifiers, prefix = "", separator = " ") {
2305
2445
  * divide by zero). For a **concave** ring the area centroid can fall outside
2306
2446
  * the shape; that limitation is accepted here, matching the doctrinal
2307
2447
  * template's plain "center of the area" placement rather than a
2308
- * point-in-polygon-safe visual center.
2448
+ * point-in-polygon-safe visual center. Exported for reuse by measures that
2449
+ * center a non-label glyph on the area (e.g. Airfield Zone's runway glyph).
2309
2450
  */
2310
2451
  function ringCentroid(verts) {
2311
2452
  const n = verts.length - 1;
@@ -2331,12 +2472,6 @@ function ringCentroid(verts) {
2331
2472
  const factor = 1 / (3 * area2);
2332
2473
  return [cx * factor, cy * factor];
2333
2474
  }
2334
- /** Joins the W/W1 date-time-group pair as `"W - W1"`; a single supplied value
2335
- * passes through unchanged; neither set yields `""`. */
2336
- function joinDtg(start, end) {
2337
- if (start && end) return `${start} - ${end}`;
2338
- return start ?? end ?? "";
2339
- }
2340
2475
  /**
2341
2476
  * Points where the closed ring `verts` crosses the horizontal line
2342
2477
  * `y = target`, one per crossing edge, plus the ring's total arc length —
@@ -2380,7 +2515,7 @@ function ringCrossingsAtY(verts, target) {
2380
2515
  * {@link buildGappedLine} uses) an "ENY" marker knocks out of the boundary —
2381
2516
  * empty unless `texts.hostile` was placed.
2382
2517
  *
2383
- * Rows are `[name, additionalInformation, joinDtg(dtgStart, dtgEnd)]`,
2518
+ * Rows are `[name (T), additionalInformation (H), dtgStart (W), dtgEnd (W1)]`,
2384
2519
  * filtered to non-empty text, stacked around the ring's {@link ringCentroid}
2385
2520
  * at `pitch = resolveLabelOffsetMeters(options, 1.2 + labelPadding)` (the 50m
2386
2521
  * no-size fallback applies until a live label size is captured — see
@@ -2414,14 +2549,18 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
2414
2549
  const padding = Math.max(0, options.labelPadding ?? 0);
2415
2550
  const [cx, cy] = ringCentroid(ringVerts);
2416
2551
  const rows = [
2417
- texts.name,
2418
- texts.additionalInformation,
2419
- joinDtg(texts.dtgStart, texts.dtgEnd)
2420
- ].filter((s) => Boolean(s && s.length > 0));
2552
+ [texts.name, "T"],
2553
+ [texts.additionalInformation, "H"],
2554
+ [texts.dtgStart, "W"],
2555
+ [texts.dtgEnd, "W1"]
2556
+ ].flatMap(([text, field]) => text ? [{
2557
+ text,
2558
+ field
2559
+ }] : []);
2421
2560
  if (rows.length > 0) {
2422
2561
  const pitch = resolveLabelOffsetMeters(options, ROW_PITCH_RATIO + padding);
2423
- rows.forEach((text, i) => {
2424
- pushLabel(out, [cx, cy + ((rows.length - 1) / 2 - i) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps);
2562
+ rows.forEach(({ text, field }, i) => {
2563
+ pushLabel(out, [cx, cy + ((rows.length - 1) / 2 - i) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, field);
2425
2564
  });
2426
2565
  }
2427
2566
  const gaps = [];
@@ -2435,7 +2574,7 @@ function pushAreaLabels(out, ringVerts, texts, options = {}) {
2435
2574
  const textWidth = texts.hostile.length * ENY_CHAR_ASPECT_RATIO * textHeight;
2436
2575
  const clearance = (ENY_CLEARANCE_RATIO + padding) * textHeight;
2437
2576
  for (const crossing of [west, east]) {
2438
- pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps);
2577
+ pushLabel(out, [crossing.x, cy], texts.hostile, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, "N");
2439
2578
  const halfExtent = .5 * (textWidth * Math.abs(crossing.along[0]) + textHeight * Math.abs(crossing.along[1])) + clearance;
2440
2579
  pushWrappedGap(gaps, crossing.arc - halfExtent, crossing.arc + halfExtent, totalArc);
2441
2580
  }
@@ -2479,8 +2618,8 @@ function boundaryFeature(verts, gaps) {
2479
2618
  * common (no `N`) case is unaffected.
2480
2619
  */
2481
2620
  function labeledAreaFeatures(positions, options, texts) {
2482
- const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$7, ...labelOptions } = options;
2483
- const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
2621
+ const { smooth = false, smoothResolution = DEFAULT_SMOOTH_RESOLUTION$9, ...labelOptions } = options;
2622
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$9);
2484
2623
  const labelFeatures = [];
2485
2624
  const features = [boundaryFeature(verts, pushAreaLabels(labelFeatures, verts, texts, labelOptions))];
2486
2625
  features.push(...labelFeatures);
@@ -2490,44 +2629,492 @@ function labeledAreaFeatures(positions, options, texts) {
2490
2629
  };
2491
2630
  }
2492
2631
  //#endregion
2493
- //#region src/generators/cm12-command-control-areas/area-of-operations.ts
2494
- /** Default options for the Area of operations control measure. */
2495
- const DEFAULT_AREA_OF_OPERATIONS_OPTIONS = {
2496
- smooth: false,
2497
- smoothResolution: 16
2632
+ //#region src/generators/cm15-maneuver-areas/area.ts
2633
+ /** Default options for the Area control measure. */
2634
+ const DEFAULT_AREA_OPTIONS = {
2635
+ smooth: false,
2636
+ smoothResolution: 16
2637
+ };
2638
+ const AREA_METADATA = {
2639
+ id: "area",
2640
+ name: "Area",
2641
+ description: "A generic maneuver area enclosed by a boundary.",
2642
+ entity: "Maneuver Areas",
2643
+ entityType: "Area",
2644
+ value: "150100",
2645
+ minCoordinates: 3,
2646
+ geometry: "area",
2647
+ geometryTypes: [
2648
+ "Polygon",
2649
+ "MultiLineString",
2650
+ "Point"
2651
+ ],
2652
+ drawRule: "Area1",
2653
+ capturesLabelSize: true,
2654
+ params: LABELED_AREA_PARAMS,
2655
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
2656
+ };
2657
+ /**
2658
+ * Creates an Area control measure: a closed area through `positions` labeled
2659
+ * at its center with an un-prefixed `T` (Field T) plus an `N` (Field N) ENY
2660
+ * marker, per {@link labeledAreaFeatures}.
2661
+ */
2662
+ function createArea(positions, options = {}, textAmplifiers = {}) {
2663
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, ""));
2664
+ }
2665
+ const AREA = defineControlMeasure({
2666
+ metadata: AREA_METADATA,
2667
+ generator: createArea,
2668
+ defaultOptions: DEFAULT_AREA_OPTIONS,
2669
+ rule: area1DrawRule,
2670
+ previewSample: {
2671
+ controlPoints: [
2672
+ [-1, -.5],
2673
+ [1, -.5],
2674
+ [1, .5],
2675
+ [-1, .5]
2676
+ ],
2677
+ textAmplifiers: {},
2678
+ options: { labelSize: 96 }
2679
+ }
2680
+ });
2681
+ //#endregion
2682
+ //#region src/generators/cm12-command-control-areas/area-of-operations.ts
2683
+ /** Default options for the Area of operations control measure. */
2684
+ const DEFAULT_AREA_OF_OPERATIONS_OPTIONS = {
2685
+ smooth: false,
2686
+ smoothResolution: 16
2687
+ };
2688
+ const AREA_OF_OPERATIONS_METADATA = {
2689
+ id: "area-of-operations",
2690
+ name: "Area of operations",
2691
+ description: "An operational area defined by a commander for land and naval forces, within which the assigned force conducts military operations.",
2692
+ entity: "Command and Control Areas",
2693
+ entityType: "Area of Operations",
2694
+ value: "120100",
2695
+ minCoordinates: 3,
2696
+ geometry: "area",
2697
+ geometryTypes: [
2698
+ "Polygon",
2699
+ "MultiLineString",
2700
+ "Point"
2701
+ ],
2702
+ drawRule: "Area1",
2703
+ capturesLabelSize: true,
2704
+ params: LABELED_AREA_PARAMS,
2705
+ textAmplifiers: AREA_TEXT_AMPLIFIERS
2706
+ };
2707
+ /**
2708
+ * Creates an Area of operations control measure: a closed area through
2709
+ * `positions` labeled at its center with "AO "-prefixed `T` (Field T) plus
2710
+ * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
2711
+ */
2712
+ function createAreaOfOperations(positions, options = {}, textAmplifiers = {}) {
2713
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"));
2714
+ }
2715
+ const AREA_OF_OPERATIONS = defineControlMeasure({
2716
+ metadata: AREA_OF_OPERATIONS_METADATA,
2717
+ generator: createAreaOfOperations,
2718
+ defaultOptions: DEFAULT_AREA_OF_OPERATIONS_OPTIONS,
2719
+ rule: area1DrawRule,
2720
+ previewSample: {
2721
+ controlPoints: [
2722
+ [-1, -.5],
2723
+ [1, -.5],
2724
+ [1, .5],
2725
+ [-1, .5]
2726
+ ],
2727
+ textAmplifiers: { T: "GREEN" },
2728
+ options: { labelSize: 96 }
2729
+ }
2730
+ });
2731
+ //#endregion
2732
+ //#region src/generators/cm12-command-control-areas/params.ts
2733
+ const SMOOTH_AREA_PARAMS = AREA_SMOOTH_PARAMS;
2734
+ const AREA_DESIGNATION_ONLY_AMPLIFIER = {
2735
+ key: "T",
2736
+ label: "Unique designation",
2737
+ description: "Field T — the area's unique designation.",
2738
+ maxLength: 21
2739
+ };
2740
+ //#endregion
2741
+ //#region src/generators/cm12-command-control-areas/named-area-of-interest.ts
2742
+ /**
2743
+ * Default options for the Named area of interest control measure. The
2744
+ * standard's example renders a straight-sided polygon, so `smooth` defaults
2745
+ * off (unlike the rounded L-15 templates) while staying available.
2746
+ */
2747
+ const DEFAULT_NAMED_AREA_OF_INTEREST_OPTIONS = {
2748
+ smooth: false,
2749
+ smoothResolution: 16
2750
+ };
2751
+ const NAMED_AREA_OF_INTEREST_METADATA = {
2752
+ id: "named-area-of-interest",
2753
+ name: "Named area of interest",
2754
+ description: "A geographical area where information is gathered to satisfy specific intelligence requirements.",
2755
+ entity: "Command and Control Areas",
2756
+ entityType: "Named Area of Interest",
2757
+ value: "120200",
2758
+ minCoordinates: 3,
2759
+ geometry: "area",
2760
+ geometryTypes: ["Polygon", "Point"],
2761
+ drawRule: "Area1",
2762
+ capturesLabelSize: true,
2763
+ params: SMOOTH_AREA_PARAMS,
2764
+ textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
2765
+ };
2766
+ /**
2767
+ * Creates a Named area of interest control measure: a closed area through
2768
+ * `positions` labeled at its center with "NAI "-prefixed `T` (Field T) —
2769
+ * the sole label row, per {@link labeledAreaFeatures}.
2770
+ */
2771
+ function createNamedAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
2772
+ return labeledAreaFeatures(positions, options, { name: composeAreaName("NAI", textAmplifiers.T) });
2773
+ }
2774
+ const NAMED_AREA_OF_INTEREST = defineControlMeasure({
2775
+ metadata: NAMED_AREA_OF_INTEREST_METADATA,
2776
+ generator: createNamedAreaOfInterest,
2777
+ defaultOptions: DEFAULT_NAMED_AREA_OF_INTEREST_OPTIONS,
2778
+ rule: area1DrawRule,
2779
+ previewSample: {
2780
+ controlPoints: [
2781
+ [-1, -.5],
2782
+ [1, -.5],
2783
+ [1, .5],
2784
+ [-1, .5]
2785
+ ],
2786
+ textAmplifiers: { T: "1" },
2787
+ options: { labelSize: 96 }
2788
+ }
2789
+ });
2790
+ //#endregion
2791
+ //#region src/generators/cm12-command-control-areas/target-area-of-interest.ts
2792
+ /**
2793
+ * Default options for the Target area of interest control measure. The
2794
+ * standard's example renders a straight-sided polygon, so `smooth` defaults
2795
+ * off (unlike the rounded L-15 templates) while staying available.
2796
+ */
2797
+ const DEFAULT_TARGET_AREA_OF_INTEREST_OPTIONS = {
2798
+ smooth: false,
2799
+ smoothResolution: 16
2800
+ };
2801
+ const TARGET_AREA_OF_INTEREST_METADATA = {
2802
+ id: "target-area-of-interest",
2803
+ name: "Target area of interest",
2804
+ description: "The geographical area where high-value targets can be acquired and engaged by friendly forces.",
2805
+ entity: "Command and Control Areas",
2806
+ entityType: "Target Area of Interest",
2807
+ value: "120300",
2808
+ minCoordinates: 3,
2809
+ geometry: "area",
2810
+ geometryTypes: ["Polygon", "Point"],
2811
+ drawRule: "Area1",
2812
+ capturesLabelSize: true,
2813
+ params: SMOOTH_AREA_PARAMS,
2814
+ textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
2815
+ };
2816
+ /**
2817
+ * Creates a Target area of interest control measure: a closed area through
2818
+ * `positions` labeled at its center with "TAI "-prefixed `T` (Field T) —
2819
+ * the sole label row, per {@link labeledAreaFeatures}.
2820
+ */
2821
+ function createTargetAreaOfInterest(positions, options = {}, textAmplifiers = {}) {
2822
+ return labeledAreaFeatures(positions, options, { name: composeAreaName("TAI", textAmplifiers.T) });
2823
+ }
2824
+ const TARGET_AREA_OF_INTEREST = defineControlMeasure({
2825
+ metadata: TARGET_AREA_OF_INTEREST_METADATA,
2826
+ generator: createTargetAreaOfInterest,
2827
+ defaultOptions: DEFAULT_TARGET_AREA_OF_INTEREST_OPTIONS,
2828
+ rule: area1DrawRule,
2829
+ previewSample: {
2830
+ controlPoints: [
2831
+ [-1, -.5],
2832
+ [1, -.5],
2833
+ [1, .5],
2834
+ [-1, .5]
2835
+ ],
2836
+ textAmplifiers: { T: "YUKON" },
2837
+ options: { labelSize: 96 }
2838
+ }
2839
+ });
2840
+ //#endregion
2841
+ //#region src/generators/cm12-command-control-areas/airfield-zone.ts
2842
+ const DEFAULT_SMOOTH_RESOLUTION$8 = 16;
2843
+ /** Long runway line's tilt off the horizontal, in radians (+15°). */
2844
+ const LONG_LINE_ANGLE = 15 * Math.PI / 180;
2845
+ /** Short runway line's tilt off the horizontal, in radians (+50°). */
2846
+ const SHORT_LINE_ANGLE = 50 * Math.PI / 180;
2847
+ /** Short line's half-length as a ratio of the long line's half-length. */
2848
+ const SHORT_LINE_RATIO = .55;
2849
+ /** Field H's offset east of the area's max-x edge, as a ratio of the resolved label offset. */
2850
+ const H_LABEL_OFFSET_RATIO = .75;
2851
+ /**
2852
+ * Default options for the Airfield zone control measure. The standard's
2853
+ * example is a rounded blob, so `smooth` defaults on.
2854
+ */
2855
+ const DEFAULT_AIRFIELD_ZONE_OPTIONS = {
2856
+ smooth: true,
2857
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$8,
2858
+ glyphSizeRatio: .55
2859
+ };
2860
+ const AIRFIELD_ZONE_METADATA = {
2861
+ id: "airfield-zone",
2862
+ name: "Airfield zone",
2863
+ description: "A zone enclosing an airfield, marked with a runway glyph; Field H carries the airfield type, runway length and other pertinent information.",
2864
+ entity: "Command and Control Areas",
2865
+ entityType: "Airfield Zone",
2866
+ value: "120400",
2867
+ minCoordinates: 3,
2868
+ geometry: "area",
2869
+ geometryTypes: [
2870
+ "Polygon",
2871
+ "MultiLineString",
2872
+ "Point"
2873
+ ],
2874
+ drawRule: "Area1",
2875
+ capturesLabelSize: true,
2876
+ params: [...SMOOTH_AREA_PARAMS, {
2877
+ key: "glyphSizeRatio",
2878
+ label: "Runway size",
2879
+ description: "Size of the runway glyph as a ratio of the area's width.",
2880
+ type: "number",
2881
+ min: .2,
2882
+ max: .9,
2883
+ step: .05
2884
+ }],
2885
+ textAmplifiers: [{
2886
+ key: "H",
2887
+ label: "Additional information",
2888
+ description: "Field H — airfield type, runway length and other information, placed outside the area's east edge.",
2889
+ maxLength: 20
2890
+ }]
2891
+ };
2892
+ /**
2893
+ * Creates an Airfield zone control measure: a closed area through
2894
+ * `positions` with a runway glyph (two crossed lines forming a shallow
2895
+ * asymmetric X) centered on the ring, and Field H placed outside the area to
2896
+ * the east when set.
2897
+ *
2898
+ * @param positions - Area anchor points (≥3); the input contract is enforced
2899
+ * at the render seam (ADR-0014).
2900
+ * @param options - {@link AirfieldZoneOptions}.
2901
+ * @param textAmplifiers - Field H only.
2902
+ */
2903
+ function createAirfieldZone(positions, options = {}, textAmplifiers = {}) {
2904
+ const { smooth = DEFAULT_AIRFIELD_ZONE_OPTIONS.smooth, smoothResolution = DEFAULT_AIRFIELD_ZONE_OPTIONS.smoothResolution, glyphSizeRatio = DEFAULT_AIRFIELD_ZONE_OPTIONS.glyphSizeRatio, ...labelOptions } = options;
2905
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$8);
2906
+ const features = [boundaryFeature(verts, [])];
2907
+ let minX = Infinity;
2908
+ let maxX = -Infinity;
2909
+ for (const [x] of verts) {
2910
+ if (x < minX) minX = x;
2911
+ if (x > maxX) maxX = x;
2912
+ }
2913
+ const bboxWidth = maxX - minX;
2914
+ const [cx, cy] = ringCentroid(verts);
2915
+ if (bboxWidth >= 1e-6) {
2916
+ const longHalf = .5 * glyphSizeRatio * bboxWidth;
2917
+ const shortHalf = longHalf * SHORT_LINE_RATIO;
2918
+ const center = [cx, cy];
2919
+ const longDir = [Math.cos(LONG_LINE_ANGLE), Math.sin(LONG_LINE_ANGLE)];
2920
+ const shortDir = [Math.cos(SHORT_LINE_ANGLE), Math.sin(SHORT_LINE_ANGLE)];
2921
+ const along = (dir, half) => [[center[0] - dir[0] * half, center[1] - dir[1] * half], [center[0] + dir[0] * half, center[1] + dir[1] * half]];
2922
+ const [longA, longB] = along(longDir, longHalf);
2923
+ const [shortA, shortB] = along(shortDir, shortHalf);
2924
+ features.push({
2925
+ type: "Feature",
2926
+ properties: {},
2927
+ geometry: {
2928
+ type: "MultiLineString",
2929
+ coordinates: [[unproject(longA[0], longA[1]), unproject(longB[0], longB[1])], [unproject(shortA[0], shortA[1]), unproject(shortB[0], shortB[1])]]
2930
+ }
2931
+ });
2932
+ }
2933
+ if (textAmplifiers.H) {
2934
+ const labelFeatures = [];
2935
+ pushLabel(labelFeatures, [maxX + H_LABEL_OFFSET_RATIO * resolveLabelOffsetMeters(labelOptions, 1), cy], textAmplifiers.H, HORIZONTAL_LABEL_ROTATION, labelSizeProps(labelOptions), "start", "H");
2936
+ features.push(...labelFeatures);
2937
+ }
2938
+ return {
2939
+ type: "FeatureCollection",
2940
+ features
2941
+ };
2942
+ }
2943
+ const AIRFIELD_ZONE = defineControlMeasure({
2944
+ metadata: AIRFIELD_ZONE_METADATA,
2945
+ generator: createAirfieldZone,
2946
+ defaultOptions: DEFAULT_AIRFIELD_ZONE_OPTIONS,
2947
+ rule: area1DrawRule,
2948
+ previewSample: {
2949
+ controlPoints: [
2950
+ [-1, -.5],
2951
+ [1, -.5],
2952
+ [1, .5],
2953
+ [-1, .5]
2954
+ ],
2955
+ textAmplifiers: { H: "570M" },
2956
+ options: { labelSize: 96 }
2957
+ }
2958
+ });
2959
+ //#endregion
2960
+ //#region src/generators/cm12-command-control-areas/echelon-labeled-area.ts
2961
+ const DEFAULT_SMOOTH_RESOLUTION$7 = 16;
2962
+ /** Default options shared by Base Camp and Guerrilla Base — echelon off, boundary rounded. */
2963
+ const DEFAULT_ECHELON_LABELED_AREA_OPTIONS = {
2964
+ echelon: "none",
2965
+ echelonSize: 750,
2966
+ echelonSizePixels: 16,
2967
+ echelonPadding: .3,
2968
+ echelonPosition: 0,
2969
+ smooth: true,
2970
+ smoothResolution: DEFAULT_SMOOTH_RESOLUTION$7
2971
+ };
2972
+ /**
2973
+ * Builds the shared Base Camp / Guerrilla Base geometry: a closed area
2974
+ * through `positions` carrying the full L-15 text amplifier template
2975
+ * ({@link pushAreaLabels}) plus an optional Field B echelon glyph straddling
2976
+ * the boundary ({@link placeEchelon}, mirroring Battle Position). The
2977
+ * echelon's masking gap and any Field N "ENY" masking gap are concatenated
2978
+ * before carving the boundary, so either (or both) opens it into a gapped
2979
+ * `MultiLineString`; with neither, the boundary is a closed `Polygon`.
2980
+ */
2981
+ function echelonLabeledAreaFeatures(positions, options, texts) {
2982
+ 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;
2983
+ const h = resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel);
2984
+ const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$7);
2985
+ const { strokes, fills, gaps: echelonGaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
2986
+ const labelFeatures = [];
2987
+ const enyGaps = pushAreaLabels(labelFeatures, verts, texts, labelOptions);
2988
+ const features = [boundaryFeature(verts, [...echelonGaps, ...enyGaps])];
2989
+ if (strokes.length > 0) features.push({
2990
+ type: "Feature",
2991
+ properties: { part: "echelon" },
2992
+ geometry: {
2993
+ type: "MultiLineString",
2994
+ coordinates: strokes
2995
+ }
2996
+ });
2997
+ if (fills.length > 0) features.push({
2998
+ type: "Feature",
2999
+ properties: {
3000
+ part: "echelon",
3001
+ fill: true
3002
+ },
3003
+ geometry: {
3004
+ type: "MultiPolygon",
3005
+ coordinates: fills.map((ring) => [ring])
3006
+ }
3007
+ });
3008
+ features.push(...labelFeatures);
3009
+ return {
3010
+ type: "FeatureCollection",
3011
+ features
3012
+ };
3013
+ }
3014
+ /**
3015
+ * `freezeOrientation` hook factory instance shared by Base Camp and
3016
+ * Guerrilla Base — see {@link makeFreezeEchelonAnchor} (ADR-0023) for the
3017
+ * mechanism; both measures default to `echelon: "none"`, so unlike Battle
3018
+ * Position's own instance (battlePosition.ts) freezing is a no-op until a
3019
+ * user explicitly picks an echelon.
3020
+ */
3021
+ const freezeEchelonLabeledAreaAnchor = makeFreezeEchelonAnchor({
3022
+ echelon: DEFAULT_ECHELON_LABELED_AREA_OPTIONS.echelon,
3023
+ smooth: DEFAULT_ECHELON_LABELED_AREA_OPTIONS.smooth,
3024
+ smoothResolution: DEFAULT_ECHELON_LABELED_AREA_OPTIONS.smoothResolution
3025
+ });
3026
+ //#endregion
3027
+ //#region src/generators/cm12-command-control-areas/base-camp.ts
3028
+ /** Default options for the Base camp control measure. */
3029
+ const DEFAULT_BASE_CAMP_OPTIONS = DEFAULT_ECHELON_LABELED_AREA_OPTIONS;
3030
+ const BASE_CAMP_METADATA = {
3031
+ id: "base-camp",
3032
+ name: "Base camp",
3033
+ description: "An evolving military facility that supports military operations of a deployed unit and provides the necessary support and services for sustained operations.",
3034
+ entity: "Command and Control Areas",
3035
+ entityType: "Base Camp",
3036
+ value: "120500",
3037
+ minCoordinates: 3,
3038
+ geometry: "area",
3039
+ geometryTypes: [
3040
+ "Polygon",
3041
+ "MultiLineString",
3042
+ "MultiPolygon",
3043
+ "Point"
3044
+ ],
3045
+ drawRule: "Area1",
3046
+ capturesLabelSize: true,
3047
+ params: ECHELON_AREA_PARAMS,
3048
+ textAmplifiers: AREA_TEXT_AMPLIFIERS
2498
3049
  };
2499
- const AREA_OF_OPERATIONS_METADATA = {
2500
- id: "area-of-operations",
2501
- name: "Area of operations",
2502
- description: "An operational area defined by a commander for land and naval forces, within which the assigned force conducts military operations.",
3050
+ /**
3051
+ * Creates a Base camp control measure: a closed area through `positions`
3052
+ * labeled at its center with "BC "-prefixed `T` (Field T) plus `H`/`W`/`W1`/`N`
3053
+ * amplifier rows, and an optional Field B echelon glyph straddling the
3054
+ * boundary, per {@link echelonLabeledAreaFeatures}.
3055
+ */
3056
+ function createBaseCamp(positions, options = {}, textAmplifiers = {}) {
3057
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "BC"));
3058
+ }
3059
+ const BASE_CAMP = defineControlMeasure({
3060
+ metadata: BASE_CAMP_METADATA,
3061
+ generator: createBaseCamp,
3062
+ defaultOptions: DEFAULT_BASE_CAMP_OPTIONS,
3063
+ rule: area1DrawRule,
3064
+ freezeOrientation: freezeEchelonLabeledAreaAnchor,
3065
+ previewSample: {
3066
+ controlPoints: [
3067
+ [-1, -.5],
3068
+ [1, -.5],
3069
+ [1, .5],
3070
+ [-1, .5]
3071
+ ],
3072
+ textAmplifiers: { T: "LEATHERNECK" },
3073
+ options: {
3074
+ smooth: true,
3075
+ labelSize: 96
3076
+ }
3077
+ }
3078
+ });
3079
+ //#endregion
3080
+ //#region src/generators/cm12-command-control-areas/guerrilla-base.ts
3081
+ /** Default options for the Guerrilla base control measure. */
3082
+ const DEFAULT_GUERRILLA_BASE_OPTIONS = DEFAULT_ECHELON_LABELED_AREA_OPTIONS;
3083
+ const GUERRILLA_BASE_METADATA = {
3084
+ id: "guerrilla-base",
3085
+ name: "Guerrilla base",
3086
+ description: "A temporary site where guerrilla installations, headquarters, and units are located.",
2503
3087
  entity: "Command and Control Areas",
2504
- entityType: "Area of Operations",
2505
- value: "120100",
3088
+ entityType: "Guerrilla Base",
3089
+ value: "120600",
2506
3090
  minCoordinates: 3,
2507
3091
  geometry: "area",
2508
3092
  geometryTypes: [
2509
3093
  "Polygon",
2510
3094
  "MultiLineString",
3095
+ "MultiPolygon",
2511
3096
  "Point"
2512
3097
  ],
2513
3098
  drawRule: "Area1",
2514
3099
  capturesLabelSize: true,
2515
- params: LABELED_AREA_PARAMS,
3100
+ params: ECHELON_AREA_PARAMS,
2516
3101
  textAmplifiers: AREA_TEXT_AMPLIFIERS
2517
3102
  };
2518
3103
  /**
2519
- * Creates an Area of operations control measure: a closed area through
2520
- * `positions` labeled at its center with "AO "-prefixed `T` (Field T) plus
2521
- * `H`/`W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
3104
+ * Creates a Guerrilla base control measure: a closed area through
3105
+ * `positions` labeled at its center with "GB "-prefixed `T` (Field T) plus
3106
+ * `H`/`W`/`W1`/`N` amplifier rows, and an optional Field B echelon glyph
3107
+ * straddling the boundary, per {@link echelonLabeledAreaFeatures}.
2522
3108
  */
2523
- function createAreaOfOperations(positions, options = {}, textAmplifiers = {}) {
2524
- return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "AO"));
3109
+ function createGuerrillaBase(positions, options = {}, textAmplifiers = {}) {
3110
+ return echelonLabeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "GB"));
2525
3111
  }
2526
- const AREA_OF_OPERATIONS = defineControlMeasure({
2527
- metadata: AREA_OF_OPERATIONS_METADATA,
2528
- generator: createAreaOfOperations,
2529
- defaultOptions: DEFAULT_AREA_OF_OPERATIONS_OPTIONS,
3112
+ const GUERRILLA_BASE = defineControlMeasure({
3113
+ metadata: GUERRILLA_BASE_METADATA,
3114
+ generator: createGuerrillaBase,
3115
+ defaultOptions: DEFAULT_GUERRILLA_BASE_OPTIONS,
2530
3116
  rule: area1DrawRule,
3117
+ freezeOrientation: freezeEchelonLabeledAreaAnchor,
2531
3118
  previewSample: {
2532
3119
  controlPoints: [
2533
3120
  [-1, -.5],
@@ -2535,14 +3122,67 @@ const AREA_OF_OPERATIONS = defineControlMeasure({
2535
3122
  [1, .5],
2536
3123
  [-1, .5]
2537
3124
  ],
2538
- textAmplifiers: { T: "GREEN" },
3125
+ textAmplifiers: { T: "GUEVARA" },
2539
3126
  options: {
2540
- labelSizePixels: 12,
2541
- labelSizeResolution: 8
3127
+ smooth: true,
3128
+ labelSize: 96
2542
3129
  }
2543
3130
  }
2544
3131
  });
2545
3132
  //#endregion
3133
+ //#region src/generators/cm12-command-control-areas/generic-c2-area.ts
3134
+ /**
3135
+ * Default options for the Generic command and control area control measure.
3136
+ * The standard's example is a rounded blob, so `smooth` defaults on.
3137
+ */
3138
+ const DEFAULT_GENERIC_C2_AREA_OPTIONS = {
3139
+ smooth: true,
3140
+ smoothResolution: 16
3141
+ };
3142
+ const GENERIC_C2_AREA_METADATA = {
3143
+ id: "generic-c2-area",
3144
+ name: "Generic command and control area",
3145
+ description: "A general-purpose command and control area carrying a designation, additional information and an effective date-time-group window.",
3146
+ entity: "Command and Control Areas",
3147
+ entityType: "Generic",
3148
+ value: "120700",
3149
+ minCoordinates: 3,
3150
+ geometry: "area",
3151
+ geometryTypes: [
3152
+ "Polygon",
3153
+ "MultiLineString",
3154
+ "Point"
3155
+ ],
3156
+ drawRule: "Area1",
3157
+ capturesLabelSize: true,
3158
+ params: LABELED_AREA_PARAMS,
3159
+ textAmplifiers: AREA_TEXT_AMPLIFIERS
3160
+ };
3161
+ /**
3162
+ * Creates a Generic command and control area control measure: a closed area
3163
+ * through `positions` labeled at its center with the bare `T` (Field T) plus
3164
+ * `H`/`W`/`W1`/`N` amplifier rows — no prefix, per {@link labeledAreaFeatures}.
3165
+ */
3166
+ function createGenericC2Area(positions, options = {}, textAmplifiers = {}) {
3167
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers));
3168
+ }
3169
+ const GENERIC_C2_AREA = defineControlMeasure({
3170
+ metadata: GENERIC_C2_AREA_METADATA,
3171
+ generator: createGenericC2Area,
3172
+ defaultOptions: DEFAULT_GENERIC_C2_AREA_OPTIONS,
3173
+ rule: area1DrawRule,
3174
+ previewSample: {
3175
+ controlPoints: [
3176
+ [-1, -.5],
3177
+ [1, -.5],
3178
+ [1, .5],
3179
+ [-1, .5]
3180
+ ],
3181
+ textAmplifiers: { T: "RIOT FOODS" },
3182
+ options: { labelSize: 96 }
3183
+ }
3184
+ });
3185
+ //#endregion
2546
3186
  //#region src/generators/cm15-maneuver-areas/assemblyArea.ts
2547
3187
  /** Default options for the Assembly area control measure. */
2548
3188
  const DEFAULT_ASSEMBLY_AREA_OPTIONS = {
@@ -2589,31 +3229,13 @@ const ASSEMBLY_AREA = defineControlMeasure({
2589
3229
  [-1, .5]
2590
3230
  ],
2591
3231
  textAmplifiers: { T: "BLUE" },
2592
- options: {
2593
- labelSizePixels: 12,
2594
- labelSizeResolution: 8
2595
- }
3232
+ options: { labelSize: 96 }
2596
3233
  }
2597
3234
  });
2598
3235
  //#endregion
2599
3236
  //#region src/generators/cm29-protection-lines/params.ts
2600
3237
  const FORTIFIED_PARAMS = [
2601
- {
2602
- key: "smooth",
2603
- label: "Smooth",
2604
- description: "Round the line's corners by curving the path through the control points.",
2605
- type: "boolean"
2606
- },
2607
- {
2608
- key: "smoothResolution",
2609
- label: "Smooth resolution",
2610
- description: "Number of samples per segment when smooth mode is enabled.",
2611
- type: "number",
2612
- min: 2,
2613
- max: 64,
2614
- step: 1,
2615
- visibleWhen: (opts) => Boolean(opts.smooth)
2616
- },
3238
+ ...SMOOTH_LINE_PARAMS,
2617
3239
  {
2618
3240
  key: "toothSizePixels",
2619
3241
  label: "Tooth size",
@@ -2636,6 +3258,7 @@ const FORTIFIED_PARAMS = [
2636
3258
  }
2637
3259
  ];
2638
3260
  const ANTITANK_DITCH_PARAMS = [
3261
+ ...SMOOTH_LINE_PARAMS,
2639
3262
  {
2640
3263
  key: "toothHeightPixels",
2641
3264
  label: "Tooth height",
@@ -2685,7 +3308,9 @@ const DEFAULT_TOOTH_WIDTH_RATIO$1 = 1.15;
2685
3308
  const DEFAULT_ANTITANK_DITCH_OPTIONS = {
2686
3309
  toothHeight: DEFAULT_TOOTH_HEIGHT,
2687
3310
  toothHeightPixels: DEFAULT_TOOTH_HEIGHT_PIXELS,
2688
- toothWidthRatio: DEFAULT_TOOTH_WIDTH_RATIO$1
3311
+ toothWidthRatio: DEFAULT_TOOTH_WIDTH_RATIO$1,
3312
+ smooth: false,
3313
+ smoothResolution: 16
2689
3314
  };
2690
3315
  const ANTITANK_DITCH_UNDER_CONSTRUCTION_METADATA = {
2691
3316
  id: "antitank-ditch-under-construction",
@@ -2746,13 +3371,25 @@ function generateAntitankDitchTriangles(projectedPoints, toothHeight, toothWidth
2746
3371
  }
2747
3372
  return triangles;
2748
3373
  }
2749
- function buildAntitankDitchTriangles(positions, options) {
3374
+ /**
3375
+ * Projects `positions` and, when `options.smooth` is set, curves the result
3376
+ * through the control points (centripetal Catmull-Rom) before the teeth are
3377
+ * traced — matching Fortified Line's base-path smoothing. Returns the input
3378
+ * unchanged (no project/unproject round trip) when smoothing is off.
3379
+ */
3380
+ function smoothedDitchBasePath(positions, options) {
3381
+ const projectedPoints = positions.map((position) => project(position[0], position[1]));
3382
+ const smoothedPath = smoothLineVerts(projectedPoints, options, 16);
2750
3383
  const toothHeight = calculateAntitankDitchHeight(options);
2751
- const toothWidthRatio = options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1;
2752
- return generateAntitankDitchTriangles(positions.map((position) => project(position[0], position[1])), toothHeight, toothWidthRatio);
3384
+ const toothWidthRatio = Math.max(EPSILON, options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1);
3385
+ return options.smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, toothWidthRatio * toothHeight) : smoothedPath;
3386
+ }
3387
+ function buildAntitankDitchTriangles(basePath, options) {
3388
+ return generateAntitankDitchTriangles(basePath, calculateAntitankDitchHeight(options), options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1);
2753
3389
  }
2754
3390
  function createAntitankDitchUnderConstruction(positions, options = {}) {
2755
- const triangles = buildAntitankDitchTriangles(positions, options);
3391
+ const basePath = smoothedDitchBasePath(positions, options);
3392
+ const triangles = buildAntitankDitchTriangles(basePath, options);
2756
3393
  return {
2757
3394
  type: "FeatureCollection",
2758
3395
  features: [{
@@ -2760,7 +3397,7 @@ function createAntitankDitchUnderConstruction(positions, options = {}) {
2760
3397
  properties: { part: "ditch" },
2761
3398
  geometry: {
2762
3399
  type: "MultiLineString",
2763
- coordinates: [positions.map((position) => [position[0], position[1]]), ...triangles.map((triangle) => triangle.map((point) => unproject(point[0], point[1])))]
3400
+ coordinates: [options.smooth ? basePath.map((point) => unproject(point[0], point[1])) : positions.map((position) => [position[0], position[1]]), ...triangles.map((triangle) => triangle.map((point) => unproject(point[0], point[1])))]
2764
3401
  }
2765
3402
  }]
2766
3403
  };
@@ -2776,7 +3413,7 @@ function createAntitankDitchCompleted(positions, options = {}) {
2776
3413
  },
2777
3414
  geometry: {
2778
3415
  type: "MultiPolygon",
2779
- coordinates: buildAntitankDitchTriangles(positions, options).map((triangle) => {
3416
+ coordinates: buildAntitankDitchTriangles(smoothedDitchBasePath(positions, options), options).map((triangle) => {
2780
3417
  return [[...triangle, triangle[0]].map((point) => unproject(point[0], point[1]))];
2781
3418
  })
2782
3419
  }
@@ -2853,6 +3490,8 @@ function createAntitankWall(positions, options = {}) {
2853
3490
  const toothHeight = calculateAntitankDitchHeight(options);
2854
3491
  const toothWidthRatio = options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO;
2855
3492
  const toothSpacingRatio = options.toothSpacingRatio ?? DEFAULT_TOOTH_SPACING_RATIO;
3493
+ const projectedPoints = positions.map((position) => project(position[0], position[1]));
3494
+ const smoothedPath = smoothLineVerts(projectedPoints, options, 16);
2856
3495
  return {
2857
3496
  type: "FeatureCollection",
2858
3497
  features: [{
@@ -2860,7 +3499,7 @@ function createAntitankWall(positions, options = {}) {
2860
3499
  properties: { part: "wall" },
2861
3500
  geometry: {
2862
3501
  type: "MultiLineString",
2863
- coordinates: [generateAntitankWallPoints(positions.map((position) => project(position[0], position[1])), toothHeight, toothWidthRatio, toothSpacingRatio).map((point) => unproject(point[0], point[1]))]
3502
+ coordinates: [generateAntitankWallPoints(options.smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, Math.max(EPSILON, toothWidthRatio) * toothHeight) : smoothedPath, toothHeight, toothWidthRatio, toothSpacingRatio).map((point) => unproject(point[0], point[1]))]
2864
3503
  }
2865
3504
  }]
2866
3505
  };
@@ -3255,70 +3894,7 @@ const BATTLE_POSITION_METADATA = {
3255
3894
  ],
3256
3895
  drawRule: "Area1",
3257
3896
  capturesLabelSize: true,
3258
- params: [
3259
- {
3260
- key: "smooth",
3261
- label: "Smooth",
3262
- description: "Round the area's corners by curving the boundary through the control points.",
3263
- type: "boolean"
3264
- },
3265
- {
3266
- key: "smoothResolution",
3267
- label: "Smooth resolution",
3268
- description: "Number of samples per segment when smooth mode is enabled.",
3269
- type: "number",
3270
- min: 2,
3271
- max: 64,
3272
- step: 1,
3273
- visibleWhen: (opts) => Boolean(opts.smooth)
3274
- },
3275
- {
3276
- key: "echelon",
3277
- label: "Echelon",
3278
- description: "Field B unit-size amplifier, drawn on the boundary.",
3279
- type: "enum",
3280
- options: ECHELON_PARAM_OPTIONS
3281
- },
3282
- {
3283
- key: "echelonSizePixels",
3284
- label: "Echelon size",
3285
- description: "Echelon glyph height in screen pixels.",
3286
- type: "number",
3287
- min: 8,
3288
- max: 48,
3289
- step: 1,
3290
- unit: "px"
3291
- },
3292
- {
3293
- key: "echelonSize",
3294
- label: "Echelon size",
3295
- description: "Echelon glyph height in meters.",
3296
- type: "number",
3297
- min: 50,
3298
- max: 2e4,
3299
- step: 50,
3300
- unit: "m"
3301
- },
3302
- {
3303
- key: "echelonPadding",
3304
- label: "Echelon padding",
3305
- description: "Gap on each side of the echelon, as a ratio of its height.",
3306
- type: "number",
3307
- min: 0,
3308
- max: 2,
3309
- step: .05
3310
- },
3311
- {
3312
- key: "echelonPosition",
3313
- label: "Echelon position",
3314
- description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
3315
- type: "number",
3316
- min: -.5,
3317
- max: .5,
3318
- step: .01
3319
- },
3320
- AREA_LABEL_PADDING_PARAM
3321
- ],
3897
+ params: ECHELON_AREA_PARAMS,
3322
3898
  textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
3323
3899
  };
3324
3900
  /**
@@ -3380,18 +3956,17 @@ function createBattlePosition(positions, options = {}, textAmplifiers = {}) {
3380
3956
  * Point: pins the echelon anchor to its current bottom-edge fraction so a
3381
3957
  * subsequent rigid rotation of `controlPoints` (e.g. the transform box's
3382
3958
  * rotate grip) does not re-derive a different "bottom edge" against the
3383
- * rotated geometry. Returns `undefined` — nothing to freeze — when:
3384
- * - `echelonAnchor` is already set (previously frozen or user-authored), or
3385
- * - the echelon resolves to none (nothing visible to pin), or
3386
- * - the ring is degenerate (no segments, or a near-zero perimeter).
3387
- */
3388
- function freezeEchelonAnchor(controlPoints, options) {
3389
- if (options.echelonAnchor !== void 0) return void 0;
3390
- if (!resolveEchelon(options.echelon ?? DEFAULT_BATTLE_POSITION_OPTIONS.echelon)) return void 0;
3391
- const perimeter = buildAreaPerimeter(buildClosedRing(controlPoints, options.smooth ?? DEFAULT_BATTLE_POSITION_OPTIONS.smooth, options.smoothResolution ?? DEFAULT_BATTLE_POSITION_OPTIONS.smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6));
3392
- if (perimeter.segs.length === 0 || perimeter.totalLength < 1e-6) return void 0;
3393
- return { echelonAnchor: perimeter.anchorTarget / perimeter.totalLength };
3394
- }
3959
+ * rotated geometry. Delegates to {@link makeFreezeEchelonAnchor}, the shared
3960
+ * factory every echelon-carrying area measure (Battle Position, Strong
3961
+ * Point, Base Camp, Guerrilla Base) builds its own instance from — this
3962
+ * export just binds Battle Position's own defaults, keeping strongPoint.ts's
3963
+ * existing import (and any other importer) unchanged.
3964
+ */
3965
+ const freezeEchelonAnchor = makeFreezeEchelonAnchor({
3966
+ echelon: DEFAULT_BATTLE_POSITION_OPTIONS.echelon,
3967
+ smooth: DEFAULT_BATTLE_POSITION_OPTIONS.smooth,
3968
+ smoothResolution: DEFAULT_BATTLE_POSITION_OPTIONS.smoothResolution
3969
+ });
3395
3970
  const BATTLE_POSITION = defineControlMeasure({
3396
3971
  metadata: BATTLE_POSITION_METADATA,
3397
3972
  generator: createBattlePosition,
@@ -3409,8 +3984,7 @@ const BATTLE_POSITION = defineControlMeasure({
3409
3984
  options: {
3410
3985
  echelonSize: 200,
3411
3986
  smooth: true,
3412
- labelSizePixels: 12,
3413
- labelSizeResolution: 8
3987
+ labelSize: 96
3414
3988
  }
3415
3989
  }
3416
3990
  });
@@ -3762,7 +4336,6 @@ const BOUNDARY_METADATA = {
3762
4336
  "Point"
3763
4337
  ],
3764
4338
  drawRule: "Line1",
3765
- capturesLabelSize: true,
3766
4339
  params: [
3767
4340
  {
3768
4341
  key: "smooth",
@@ -3896,7 +4469,7 @@ const BOUNDARY_METADATA = {
3896
4469
  * unit 1 (left-of-travel side), `T1`/`AS1` label unit 2 (right-of-travel side).
3897
4470
  */
3898
4471
  function createBoundary(positions, options = {}, textAmplifiers = {}) {
3899
- const { echelon = DEFAULT_BOUNDARY_OPTIONS.echelon, echelonSize = DEFAULT_BOUNDARY_OPTIONS.echelonSize, echelonSizePixels, echelonPadding = DEFAULT_BOUNDARY_OPTIONS.echelonPadding, metersPerPixel, labelSizeZoom, labelSizeResolution, labelRepetitions = DEFAULT_BOUNDARY_OPTIONS.labelRepetitions, labelSpacing = DEFAULT_BOUNDARY_OPTIONS.labelSpacing, labelPadding = DEFAULT_BOUNDARY_OPTIONS.labelPadding, labelOrientation = DEFAULT_BOUNDARY_OPTIONS.labelOrientation, smooth = DEFAULT_BOUNDARY_OPTIONS.smooth, smoothResolution = DEFAULT_BOUNDARY_OPTIONS.smoothResolution } = options;
4472
+ 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;
3900
4473
  const unit1Designator = textAmplifiers.T ?? "";
3901
4474
  const unit1Country = textAmplifiers.AS ?? "";
3902
4475
  const unit2Designator = textAmplifiers.T1 ?? "";
@@ -3906,14 +4479,7 @@ function createBoundary(positions, options = {}, textAmplifiers = {}) {
3906
4479
  h = Math.max(EPSILON, h);
3907
4480
  const reps = Math.max(1, Math.round(labelRepetitions));
3908
4481
  const spec = resolveEchelon(echelon);
3909
- const screenAnchored = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0;
3910
- const refResolution = screenAnchored ? metersPerPixel : labelSizeResolution;
3911
- const labelSize = {};
3912
- if (refResolution !== void 0 && refResolution > 0) labelSize.textSizePixels = Math.round(h / refResolution);
3913
- if (!screenAnchored) {
3914
- if (labelSizeResolution !== void 0) labelSize.textSizeResolution = labelSizeResolution;
3915
- if (labelSizeZoom !== void 0) labelSize.textSizeZoom = labelSizeZoom;
3916
- }
4482
+ const labelSize = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0 ? { textSizePixels: echelonSizePixels } : { textSizeMeters: h };
3917
4483
  const rawVerts = positions.map((p) => project(p[0], p[1]));
3918
4484
  const verts = smooth ? catmullRom(rawVerts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$4)) : rawVerts;
3919
4485
  const glyphStrokes = [];
@@ -4018,13 +4584,24 @@ const BOUNDARY = defineControlMeasure({
4018
4584
  rule: line1DrawRule,
4019
4585
  previewSample: {
4020
4586
  controlPoints: [[-2.5, 0], [2.5, 0]],
4021
- options: { echelonSize: 375 }
4587
+ options: {
4588
+ echelonSize: 375,
4589
+ labelPadding: .5
4590
+ },
4591
+ textAmplifiers: {
4592
+ T: "2INF",
4593
+ T1: "3INF"
4594
+ }
4022
4595
  }
4023
4596
  });
4024
4597
  //#endregion
4025
4598
  //#region src/generators/cm11-command-control-lines/light-line.ts
4026
4599
  /** Default options for the Light line control measure. */
4027
- const DEFAULT_LIGHT_LINE_OPTIONS = { includePrefix: true };
4600
+ const DEFAULT_LIGHT_LINE_OPTIONS = {
4601
+ includePrefix: true,
4602
+ smooth: false,
4603
+ smoothResolution: 12
4604
+ };
4028
4605
  const LIGHT_LINE_METADATA = {
4029
4606
  id: "light-line",
4030
4607
  name: "Light line",
@@ -4038,13 +4615,15 @@ const LIGHT_LINE_METADATA = {
4038
4615
  drawRule: "Line1",
4039
4616
  capturesLabelSize: true,
4040
4617
  params: [
4618
+ ...SMOOTH_LINE_PARAMS,
4041
4619
  {
4042
4620
  key: "phaseLineName",
4043
4621
  label: "Phase line name",
4044
4622
  description: "Name the line as a phase line — rendered \"PL <name>\" beyond each end of the line.",
4045
4623
  type: "text",
4046
4624
  placeholder: "DAVID",
4047
- maxLength: 21
4625
+ maxLength: 21,
4626
+ field: "T"
4048
4627
  },
4049
4628
  {
4050
4629
  key: "includePrefix",
@@ -4082,16 +4661,17 @@ const LIGHT_LINE = defineControlMeasure({
4082
4661
  rule: line1DrawRule,
4083
4662
  previewSample: {
4084
4663
  controlPoints: [[-1, 0], [1, 0]],
4085
- options: {
4086
- labelSizePixels: 12,
4087
- labelSizeResolution: 8
4088
- }
4664
+ options: { labelSize: 96 }
4089
4665
  }
4090
4666
  });
4091
4667
  //#endregion
4092
4668
  //#region src/generators/cm11-command-control-lines/engineer-work-line.ts
4093
4669
  /** Default options for the Engineer work line control measure. */
4094
- const DEFAULT_ENGINEER_WORK_LINE_OPTIONS = { includePrefix: true };
4670
+ const DEFAULT_ENGINEER_WORK_LINE_OPTIONS = {
4671
+ includePrefix: true,
4672
+ smooth: false,
4673
+ smoothResolution: 12
4674
+ };
4095
4675
  const ENGINEER_WORK_LINE_METADATA = {
4096
4676
  id: "engineer-work-line",
4097
4677
  name: "Engineer work line",
@@ -4105,13 +4685,15 @@ const ENGINEER_WORK_LINE_METADATA = {
4105
4685
  drawRule: "Line1",
4106
4686
  capturesLabelSize: true,
4107
4687
  params: [
4688
+ ...SMOOTH_LINE_PARAMS,
4108
4689
  {
4109
4690
  key: "phaseLineName",
4110
4691
  label: "Phase line name",
4111
4692
  description: "Name the line as a phase line — rendered \"PL <name>\" beyond each end of the line.",
4112
4693
  type: "text",
4113
4694
  placeholder: "DAVID",
4114
- maxLength: 21
4695
+ maxLength: 21,
4696
+ field: "T"
4115
4697
  },
4116
4698
  {
4117
4699
  key: "includePrefix",
@@ -4173,17 +4755,19 @@ function createEngineerWorkLine(positions, options = {}, textAmplifiers = {}) {
4173
4755
  placement: "above",
4174
4756
  ...options
4175
4757
  }, "EWL").features];
4176
- const { segments, totalLength } = polylineSegments(positions.map((p) => project(p[0], p[1])));
4758
+ const { segments, totalLength } = polylineSegments(smoothLineVerts(positions.map((p) => project(p[0], p[1])), options, 12));
4177
4759
  const frame = pointAlongPolyline(segments, totalLength, .5);
4178
4760
  if (frame) {
4179
4761
  const units = [{
4180
4762
  side: 1,
4181
4763
  designator: textAmplifiers.T ?? "",
4182
- country: textAmplifiers.AS ?? ""
4764
+ country: textAmplifiers.AS ?? "",
4765
+ amplifierField: "T"
4183
4766
  }, {
4184
4767
  side: -1,
4185
4768
  designator: textAmplifiers.T1 ?? "",
4186
- country: textAmplifiers.AS1 ?? ""
4769
+ country: textAmplifiers.AS1 ?? "",
4770
+ amplifierField: "T1"
4187
4771
  }];
4188
4772
  const labelFeatures = [];
4189
4773
  pushUnitLabels(labelFeatures, {
@@ -4208,21 +4792,20 @@ const ENGINEER_WORK_LINE = defineControlMeasure({
4208
4792
  previewSample: {
4209
4793
  controlPoints: [[-2.5, 0], [2.5, 0]],
4210
4794
  textAmplifiers: {
4211
- T: "326EN BN",
4212
- AS: "USA",
4213
- T1: "127EN BN",
4214
- AS1: "USA"
4795
+ T: "2BN",
4796
+ T1: "3BN"
4215
4797
  },
4216
- options: {
4217
- labelSizePixels: 12,
4218
- labelSizeResolution: 20
4219
- }
4798
+ options: { labelSize: 240 }
4220
4799
  }
4221
4800
  });
4222
4801
  //#endregion
4223
4802
  //#region src/generators/cm11-command-control-lines/generic-c2-line.ts
4224
4803
  /** Default options for the Generic command and control line. */
4225
- const DEFAULT_GENERIC_C2_LINE_OPTIONS = { includePrefix: true };
4804
+ const DEFAULT_GENERIC_C2_LINE_OPTIONS = {
4805
+ includePrefix: true,
4806
+ smooth: false,
4807
+ smoothResolution: 12
4808
+ };
4226
4809
  const GENERIC_C2_LINE_METADATA = {
4227
4810
  id: "generic-c2-line",
4228
4811
  name: "Generic command and control line",
@@ -4236,13 +4819,15 @@ const GENERIC_C2_LINE_METADATA = {
4236
4819
  drawRule: "Line1",
4237
4820
  capturesLabelSize: true,
4238
4821
  params: [
4822
+ ...SMOOTH_LINE_PARAMS,
4239
4823
  {
4240
4824
  key: "phaseLineName",
4241
4825
  label: "Phase line name",
4242
4826
  description: "Name the line as a phase line — rendered \"PL <name>\" beyond each end of the line.",
4243
4827
  type: "text",
4244
4828
  placeholder: "DAVID",
4245
- maxLength: 21
4829
+ maxLength: 21,
4830
+ field: "T"
4246
4831
  },
4247
4832
  {
4248
4833
  key: "includePrefix",
@@ -4298,20 +4883,23 @@ const GENERIC_C2_LINE_METADATA = {
4298
4883
  * `base + perp·offset` and the other at `base − perp·offset`.
4299
4884
  */
4300
4885
  function createGenericC2Line(positions, options = {}, textAmplifiers = {}) {
4301
- const { phaseLineName, includePrefix = true, ...sizeOptions } = options;
4886
+ const { phaseLineName, includePrefix = true, smooth, smoothResolution, ...sizeOptions } = options;
4887
+ const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
4888
+ smooth,
4889
+ smoothResolution
4890
+ }, 12);
4302
4891
  const features = [{
4303
4892
  type: "Feature",
4304
4893
  properties: { part: "generic-c2-line" },
4305
4894
  geometry: {
4306
4895
  type: "LineString",
4307
- coordinates: positions
4896
+ coordinates: smooth ? verts.map((v) => unproject(v[0], v[1])) : positions
4308
4897
  }
4309
4898
  }];
4310
4899
  const above = [textAmplifiers.H, textAmplifiers.T].filter((v) => Boolean(v)).join(" ");
4311
4900
  const below = [textAmplifiers.W, textAmplifiers.W1].filter((v) => Boolean(v)).join(" - ");
4312
4901
  const trimmedPhaseLineName = phaseLineName?.trim();
4313
4902
  if (above.length > 0 || below.length > 0 || trimmedPhaseLineName) {
4314
- const verts = positions.map((p) => project(p[0], p[1]));
4315
4903
  const labelFeatures = [];
4316
4904
  if (above.length > 0 || below.length > 0) {
4317
4905
  const frames = aboveEndFrames(verts, sizeOptions);
@@ -4340,15 +4928,12 @@ const GENERIC_C2_LINE = defineControlMeasure({
4340
4928
  previewSample: {
4341
4929
  controlPoints: [[-2.5, 0], [2.5, 0]],
4342
4930
  textAmplifiers: {
4343
- H: "IDR",
4344
- T: "METH",
4345
- W: "221735ZAUG2018",
4346
- W1: "312359ZDEC2099"
4931
+ H: "<H>",
4932
+ T: "<T>",
4933
+ W: "<W>",
4934
+ W1: "<W1>"
4347
4935
  },
4348
- options: {
4349
- labelSizePixels: 12,
4350
- labelSizeResolution: 20
4351
- }
4936
+ options: { labelSize: 240 }
4352
4937
  }
4353
4938
  });
4354
4939
  //#endregion
@@ -4371,21 +4956,21 @@ function createArrowHead$1(tip, dir, length, width) {
4371
4956
  ];
4372
4957
  }
4373
4958
  /**
4374
- * Builds the glyph label as a Point feature, forwarding any captured label-size
4375
- * metadata to adapters for styling. `point` is in projected meters; the rotation
4376
- * must already be in the generator's feature-rotation convention. Shared by every
4377
- * mission-task measure that centers a glyph on its symbol.
4959
+ * Builds the glyph label as a Point feature, forwarding the resolved size
4960
+ * anchor (`textSizeMeters`/`textSizePixels`, ADR-0028) to adapters for
4961
+ * styling. `point` is in projected meters; the rotation must already be in
4962
+ * the generator's feature-rotation convention. Shared by every mission-task
4963
+ * measure that centers a glyph on its symbol.
4378
4964
  */
4379
4965
  function createLabelFeature(point, text, rotation, labelSize = {}) {
4380
4966
  return {
4381
4967
  type: "Feature",
4382
4968
  properties: {
4383
4969
  part: "label",
4970
+ labelPlacement: false,
4384
4971
  text,
4385
4972
  rotation,
4386
- ...labelSize.pixels !== void 0 ? { textSizePixels: labelSize.pixels } : {},
4387
- ...labelSize.zoom !== void 0 ? { textSizeZoom: labelSize.zoom } : {},
4388
- ...labelSize.resolution !== void 0 ? { textSizeResolution: labelSize.resolution } : {}
4973
+ ...labelSizeProps(labelSize)
4389
4974
  },
4390
4975
  geometry: {
4391
4976
  type: "Point",
@@ -4550,9 +5135,8 @@ function createBlockMissionTaskSymbol(coordinates, options = {}) {
4550
5135
  ]
4551
5136
  }
4552
5137
  }, createLabelFeature(labelPoint, "B", labelRotation, {
4553
- pixels: options.labelSizePixels,
4554
- zoom: options.labelSizeZoom,
4555
- resolution: options.labelSizeResolution
5138
+ labelSize: options.labelSize,
5139
+ labelSizePixels: options.labelSizePixels
4556
5140
  })]
4557
5141
  };
4558
5142
  }
@@ -4635,9 +5219,8 @@ function createBreachSymbol(coordinates, options = {}) {
4635
5219
  labelText: "B",
4636
5220
  labelGapRatio: options.labelGapRatio ?? DEFAULT_BREACH_OPTIONS.labelGapRatio,
4637
5221
  labelSize: {
4638
- pixels: options.labelSizePixels,
4639
- zoom: options.labelSizeZoom,
4640
- resolution: options.labelSizeResolution
5222
+ labelSize: options.labelSize,
5223
+ labelSizePixels: options.labelSizePixels
4641
5224
  },
4642
5225
  buildEndCaps: ({ p1, p2, dirFront, perpDir, frontLength }) => {
4643
5226
  const halfTick = frontLength * tickLengthRatio / 2;
@@ -4719,9 +5302,8 @@ function createBypassSymbol(coordinates, options = {}) {
4719
5302
  labelText: "B",
4720
5303
  labelGapRatio: options.labelGapRatio ?? DEFAULT_BYPASS_OPTIONS.labelGapRatio,
4721
5304
  labelSize: {
4722
- pixels: options.labelSizePixels,
4723
- zoom: options.labelSizeZoom,
4724
- resolution: options.labelSizeResolution
5305
+ labelSize: options.labelSize,
5306
+ labelSizePixels: options.labelSizePixels
4725
5307
  },
4726
5308
  buildEndCaps: ({ p1, p2, frontLength, rearTop }) => {
4727
5309
  const arrowheadLength = frontLength * arrowheadLengthRatio;
@@ -4808,9 +5390,8 @@ function createCanalizeSymbol(coordinates, options = {}) {
4808
5390
  labelText: "C",
4809
5391
  labelGapRatio: options.labelGapRatio ?? DEFAULT_CANALIZE_OPTIONS.labelGapRatio,
4810
5392
  labelSize: {
4811
- pixels: options.labelSizePixels,
4812
- zoom: options.labelSizeZoom,
4813
- resolution: options.labelSizeResolution
5393
+ labelSize: options.labelSize,
5394
+ labelSizePixels: options.labelSizePixels
4814
5395
  },
4815
5396
  buildEndCaps: ({ p1, p2, dirFront, perpDir, frontLength }) => {
4816
5397
  const halfTick = frontLength * tickLengthRatio / 2;
@@ -5539,9 +6120,8 @@ function createClearSymbol(coordinates, options = {}) {
5539
6120
  return {
5540
6121
  type: "FeatureCollection",
5541
6122
  features: [mainFeature, createLabelFeature(shaftLength > 1e-6 ? vecAdd(labelTip, vecScale(perpToRear, shaftLength * .5)) : labelTip, "C", labelRotation, {
5542
- pixels: options.labelSizePixels,
5543
- zoom: options.labelSizeZoom,
5544
- resolution: options.labelSizeResolution
6123
+ labelSize: options.labelSize,
6124
+ labelSizePixels: options.labelSizePixels
5545
6125
  })]
5546
6126
  };
5547
6127
  }
@@ -5649,9 +6229,8 @@ function createDelaySymbol(coordinates, options = {}) {
5649
6229
  return {
5650
6230
  type: "FeatureCollection",
5651
6231
  features: [lineFeature, createLabelFeature(labelPoint, "D", normalizeRadians(Math.PI + lineRotation), {
5652
- pixels: options.labelSizePixels,
5653
- zoom: options.labelSizeZoom,
5654
- resolution: options.labelSizeResolution
6232
+ labelSize: options.labelSize,
6233
+ labelSizePixels: options.labelSizePixels
5655
6234
  })]
5656
6235
  };
5657
6236
  }
@@ -5883,6 +6462,56 @@ const DISRUPT = defineControlMeasure({
5883
6462
  previewSample: { controlPoints: [[0, .4], [0, -.4]] }
5884
6463
  });
5885
6464
  //#endregion
6465
+ //#region src/generators/cm15-maneuver-areas/dropZone.ts
6466
+ /** Default options for the Drop zone control measure. */
6467
+ const DEFAULT_DROP_ZONE_OPTIONS = {
6468
+ smooth: false,
6469
+ smoothResolution: 16
6470
+ };
6471
+ const DROP_ZONE_METADATA = {
6472
+ id: "drop-zone",
6473
+ name: "Drop zone",
6474
+ description: "A specified area upon which airborne troops, equipment, or supplies are airdropped.",
6475
+ entity: "Maneuver Areas",
6476
+ entityType: "Drop Zone",
6477
+ value: "150600",
6478
+ minCoordinates: 3,
6479
+ geometry: "area",
6480
+ geometryTypes: [
6481
+ "Polygon",
6482
+ "MultiLineString",
6483
+ "Point"
6484
+ ],
6485
+ drawRule: "Area1",
6486
+ capturesLabelSize: true,
6487
+ params: LABELED_AREA_PARAMS,
6488
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
6489
+ };
6490
+ /**
6491
+ * Creates a Drop zone control measure: a closed area through `positions`
6492
+ * labeled at its center with "DZ "-prefixed `T` (Field T) plus an `N`
6493
+ * (Field N) ENY marker, per {@link labeledAreaFeatures}.
6494
+ */
6495
+ function createDropZone(positions, options = {}, textAmplifiers = {}) {
6496
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "DZ"));
6497
+ }
6498
+ const DROP_ZONE = defineControlMeasure({
6499
+ metadata: DROP_ZONE_METADATA,
6500
+ generator: createDropZone,
6501
+ defaultOptions: DEFAULT_DROP_ZONE_OPTIONS,
6502
+ rule: area1DrawRule,
6503
+ previewSample: {
6504
+ controlPoints: [
6505
+ [-1, -.5],
6506
+ [1, -.5],
6507
+ [1, .5],
6508
+ [-1, .5]
6509
+ ],
6510
+ textAmplifiers: { T: "HAWK" },
6511
+ options: { labelSize: 96 }
6512
+ }
6513
+ });
6514
+ //#endregion
5886
6515
  //#region src/generators/cm15-maneuver-areas/encirclement.ts
5887
6516
  const DEFAULT_BARB_SPACING_RATIO = .4;
5888
6517
  const DEFAULT_BARB_LENGTH_RATIO = .16;
@@ -6030,17 +6659,66 @@ const ENCIRCLEMENT = defineControlMeasure({
6030
6659
  [-.4, .85],
6031
6660
  [-1, .5]
6032
6661
  ],
6033
- textAmplifiers: { N: "ENY" },
6662
+ textAmplifiers: {},
6034
6663
  options: {
6035
6664
  barbSpacingRatio: .4,
6036
6665
  barbLengthRatio: .18,
6037
6666
  smooth: true,
6038
- labelSizePixels: 12,
6039
- labelSizeResolution: 8
6667
+ labelSize: 96
6040
6668
  }
6041
6669
  }
6042
6670
  });
6043
6671
  //#endregion
6672
+ //#region src/generators/cm15-maneuver-areas/extractionZone.ts
6673
+ /** Default options for the Extraction zone control measure. */
6674
+ const DEFAULT_EXTRACTION_ZONE_OPTIONS = {
6675
+ smooth: false,
6676
+ smoothResolution: 16
6677
+ };
6678
+ const EXTRACTION_ZONE_METADATA = {
6679
+ id: "extraction-zone",
6680
+ name: "Extraction zone",
6681
+ description: "A specified drop zone used for the delivery of supplies and/or equipment by means of an extraction technique from an aircraft flying very close to the ground.",
6682
+ entity: "Maneuver Areas",
6683
+ entityType: "Extraction Zone",
6684
+ value: "150700",
6685
+ minCoordinates: 3,
6686
+ geometry: "area",
6687
+ geometryTypes: [
6688
+ "Polygon",
6689
+ "MultiLineString",
6690
+ "Point"
6691
+ ],
6692
+ drawRule: "Area1",
6693
+ capturesLabelSize: true,
6694
+ params: LABELED_AREA_PARAMS,
6695
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
6696
+ };
6697
+ /**
6698
+ * Creates an Extraction zone control measure: a closed area through
6699
+ * `positions` labeled at its center with "EZ "-prefixed `T` (Field T) plus
6700
+ * an `N` (Field N) ENY marker, per {@link labeledAreaFeatures}.
6701
+ */
6702
+ function createExtractionZone(positions, options = {}, textAmplifiers = {}) {
6703
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "EZ"));
6704
+ }
6705
+ const EXTRACTION_ZONE = defineControlMeasure({
6706
+ metadata: EXTRACTION_ZONE_METADATA,
6707
+ generator: createExtractionZone,
6708
+ defaultOptions: DEFAULT_EXTRACTION_ZONE_OPTIONS,
6709
+ rule: area1DrawRule,
6710
+ previewSample: {
6711
+ controlPoints: [
6712
+ [-1, -.5],
6713
+ [1, -.5],
6714
+ [1, .5],
6715
+ [-1, .5]
6716
+ ],
6717
+ textAmplifiers: { T: "ROCK" },
6718
+ options: { labelSize: 96 }
6719
+ }
6720
+ });
6721
+ //#endregion
6044
6722
  //#region src/internal/field-of-fire.ts
6045
6723
  /**
6046
6724
  * Builds the open (stroked) arrowhead at the outward tip of one arm. The two
@@ -6411,7 +7089,9 @@ const DEFAULT_ARC_SEGMENTS = 16;
6411
7089
  const DEFAULT_FLOT_OPTIONS = {
6412
7090
  radius: DEFAULT_RADIUS,
6413
7091
  radiusPixels: DEFAULT_RADIUS_PIXELS,
6414
- arcSegments: DEFAULT_ARC_SEGMENTS
7092
+ arcSegments: DEFAULT_ARC_SEGMENTS,
7093
+ smooth: false,
7094
+ smoothResolution: 16
6415
7095
  };
6416
7096
  const FLOT_METADATA = {
6417
7097
  id: "flot",
@@ -6425,6 +7105,7 @@ const FLOT_METADATA = {
6425
7105
  geometryTypes: ["LineString"],
6426
7106
  drawRule: "Line1",
6427
7107
  params: [
7108
+ ...SMOOTH_LINE_PARAMS,
6428
7109
  {
6429
7110
  key: "radiusPixels",
6430
7111
  label: "Radius",
@@ -6539,21 +7220,31 @@ function processSegment(p1, p2, radius, arcSegments, isFirstSegment) {
6539
7220
  * ```
6540
7221
  */
6541
7222
  function createFLOT(positions, options = {}) {
6542
- const { radius = DEFAULT_RADIUS, radiusPixels, metersPerPixel, arcSegments = DEFAULT_ARC_SEGMENTS } = options;
7223
+ const { radius = DEFAULT_RADIUS, radiusPixels, metersPerPixel, arcSegments = DEFAULT_ARC_SEGMENTS, smooth = DEFAULT_FLOT_OPTIONS.smooth, smoothResolution = DEFAULT_FLOT_OPTIONS.smoothResolution } = options;
6543
7224
  let effectiveRadius = radius;
6544
7225
  if (radiusPixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveRadius = radiusPixels * metersPerPixel;
6545
7226
  const safeRadius = Math.max(EPSILON, effectiveRadius);
6546
7227
  const safeArcSegments = Math.max(3, Math.round(arcSegments));
6547
7228
  const projectedPoints = positions.map((p) => project(p[0], p[1]));
7229
+ const smoothedPath = smoothLineVerts(projectedPoints, {
7230
+ smooth,
7231
+ smoothResolution
7232
+ }, 16);
7233
+ const basePath = smooth && projectedPoints.length >= 3 ? evenlySpacePath(smoothedPath, 2 * safeRadius) : smoothedPath;
6548
7234
  const allPoints = [];
6549
- for (let i = 0; i < projectedPoints.length - 1; i++) {
6550
- const p1 = projectedPoints[i];
6551
- const p2 = projectedPoints[i + 1];
7235
+ for (let i = 0; i < basePath.length - 1; i++) {
7236
+ const p1 = basePath[i];
7237
+ const p2 = basePath[i + 1];
6552
7238
  const segmentPoints = processSegment(p1, p2, safeRadius, safeArcSegments, i === 0);
6553
7239
  allPoints.push(...segmentPoints);
6554
7240
  }
6555
- if (allPoints.length > 0 && projectedPoints.length > 1) {
6556
- const lastOriginal = projectedPoints[projectedPoints.length - 1];
7241
+ if (allPoints.length > 0 && basePath.length > 1) {
7242
+ const firstOriginal = basePath[0];
7243
+ const firstGenerated = allPoints[0];
7244
+ if (vecMag(vecSub(firstOriginal, firstGenerated)) > 1e-6) allPoints.unshift(firstOriginal);
7245
+ }
7246
+ if (allPoints.length > 0 && basePath.length > 1) {
7247
+ const lastOriginal = basePath[basePath.length - 1];
6557
7248
  const lastGenerated = allPoints[allPoints.length - 1];
6558
7249
  if (vecMag(vecSub(lastOriginal, lastGenerated)) > 1e-6) allPoints.push(lastOriginal);
6559
7250
  }
@@ -6579,7 +7270,11 @@ const FLOT = defineControlMeasure({
6579
7270
  //#endregion
6580
7271
  //#region src/generators/cm14-maneuver-lines/phase-line.ts
6581
7272
  /** Default options for the Phase line control measure. */
6582
- const DEFAULT_PHASE_LINE_OPTIONS = { includePrefix: true };
7273
+ const DEFAULT_PHASE_LINE_OPTIONS = {
7274
+ includePrefix: true,
7275
+ smooth: false,
7276
+ smoothResolution: 12
7277
+ };
6583
7278
  const PHASE_LINE_METADATA = {
6584
7279
  id: "phase-line",
6585
7280
  name: "Phase line",
@@ -6592,20 +7287,24 @@ const PHASE_LINE_METADATA = {
6592
7287
  geometryTypes: ["LineString", "Point"],
6593
7288
  drawRule: "Line1",
6594
7289
  capturesLabelSize: true,
6595
- params: [{
6596
- key: "includePrefix",
6597
- label: "Show PL prefix",
6598
- description: "Prefix the end labels with \"PL\" (or show a bare \"PL\" with no designation).",
6599
- type: "boolean"
6600
- }, {
6601
- key: "labelPadding",
6602
- label: "Label padding",
6603
- description: "Extra clearance between the line and its labels, as a ratio of the label height",
6604
- type: "number",
6605
- min: 0,
6606
- max: 2,
6607
- step: .05
6608
- }],
7290
+ params: [
7291
+ ...SMOOTH_LINE_PARAMS,
7292
+ {
7293
+ key: "includePrefix",
7294
+ label: "Show PL prefix",
7295
+ description: "Prefix the end labels with \"PL\" (or show a bare \"PL\" with no designation).",
7296
+ type: "boolean"
7297
+ },
7298
+ {
7299
+ key: "labelPadding",
7300
+ label: "Label padding",
7301
+ description: "Extra clearance between the line and its labels, as a ratio of the label height",
7302
+ type: "number",
7303
+ min: 0,
7304
+ max: 2,
7305
+ step: .05
7306
+ }
7307
+ ],
6609
7308
  textAmplifiers: [{
6610
7309
  key: "T",
6611
7310
  label: "Unique designation",
@@ -6635,16 +7334,17 @@ const PHASE_LINE = defineControlMeasure({
6635
7334
  previewSample: {
6636
7335
  controlPoints: [[-1, 0], [1, 0]],
6637
7336
  textAmplifiers: { T: "ECHO" },
6638
- options: {
6639
- labelSizePixels: 12,
6640
- labelSizeResolution: 8
6641
- }
7337
+ options: { labelSize: 96 }
6642
7338
  }
6643
7339
  });
6644
7340
  //#endregion
6645
7341
  //#region src/generators/cm14-maneuver-lines/handover-line.ts
6646
7342
  /** Default options for the Handover line control measure. */
6647
- const DEFAULT_HANDOVER_LINE_OPTIONS = { includePrefix: true };
7343
+ const DEFAULT_HANDOVER_LINE_OPTIONS = {
7344
+ includePrefix: true,
7345
+ smooth: false,
7346
+ smoothResolution: 12
7347
+ };
6648
7348
  const HANDOVER_LINE_METADATA = {
6649
7349
  id: "handover-line",
6650
7350
  name: "Handover line",
@@ -6658,13 +7358,15 @@ const HANDOVER_LINE_METADATA = {
6658
7358
  drawRule: "Line1",
6659
7359
  capturesLabelSize: true,
6660
7360
  params: [
7361
+ ...SMOOTH_LINE_PARAMS,
6661
7362
  {
6662
7363
  key: "phaseLineName",
6663
7364
  label: "Phase line name",
6664
7365
  description: "Name the line as a phase line — rendered \"PL <name>\" beyond each end of the line.",
6665
7366
  type: "text",
6666
7367
  placeholder: "DAVID",
6667
- maxLength: 21
7368
+ maxLength: 21,
7369
+ field: "T"
6668
7370
  },
6669
7371
  {
6670
7372
  key: "includePrefix",
@@ -6702,16 +7404,17 @@ const HANDOVER_LINE = defineControlMeasure({
6702
7404
  rule: line1DrawRule,
6703
7405
  previewSample: {
6704
7406
  controlPoints: [[-1, 0], [1, 0]],
6705
- options: {
6706
- labelSizePixels: 12,
6707
- labelSizeResolution: 8
6708
- }
7407
+ options: { labelSize: 96 }
6709
7408
  }
6710
7409
  });
6711
7410
  //#endregion
6712
7411
  //#region src/generators/cm14-maneuver-lines/battle-handover-line.ts
6713
7412
  /** Default options for the Battle handover line control measure. */
6714
- const DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS = { includePrefix: true };
7413
+ const DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS = {
7414
+ includePrefix: true,
7415
+ smooth: false,
7416
+ smoothResolution: 12
7417
+ };
6715
7418
  const BATTLE_HANDOVER_LINE_METADATA = {
6716
7419
  id: "battle-handover-line",
6717
7420
  name: "Battle handover line",
@@ -6725,13 +7428,15 @@ const BATTLE_HANDOVER_LINE_METADATA = {
6725
7428
  drawRule: "Line1",
6726
7429
  capturesLabelSize: true,
6727
7430
  params: [
7431
+ ...SMOOTH_LINE_PARAMS,
6728
7432
  {
6729
7433
  key: "phaseLineName",
6730
7434
  label: "Phase line name",
6731
7435
  description: "Name the line as a phase line — rendered \"PL <name>\" beyond each end of the line.",
6732
7436
  type: "text",
6733
7437
  placeholder: "DAVID",
6734
- maxLength: 21
7438
+ maxLength: 21,
7439
+ field: "T"
6735
7440
  },
6736
7441
  {
6737
7442
  key: "includePrefix",
@@ -6769,10 +7474,7 @@ const BATTLE_HANDOVER_LINE = defineControlMeasure({
6769
7474
  rule: line1DrawRule,
6770
7475
  previewSample: {
6771
7476
  controlPoints: [[-1, 0], [1, 0]],
6772
- options: {
6773
- labelSizePixels: 12,
6774
- labelSizeResolution: 8
6775
- }
7477
+ options: { labelSize: 96 }
6776
7478
  }
6777
7479
  });
6778
7480
  //#endregion
@@ -6919,7 +7621,7 @@ function createFortifiedLine(positions, options = {}) {
6919
7621
  properties: {},
6920
7622
  geometry: {
6921
7623
  type: "LineString",
6922
- coordinates: generateFortifiedPoints(smooth ? catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)) : projectedPoints, safeSize).map((p) => unproject(p[0], p[1]))
7624
+ coordinates: generateFortifiedPoints(smooth ? evenlySpacePath(catmullRom(projectedPoints, normalizeSmoothResolution$2(smoothResolution, DEFAULT_SMOOTH_RESOLUTION$1)), 2 * safeSize) : projectedPoints, safeSize).map((p) => unproject(p[0], p[1]))
6923
7625
  }
6924
7626
  }]
6925
7627
  };
@@ -6971,7 +7673,7 @@ function createFortifiedArea(positions, options = {}, textAmplifiers = {}) {
6971
7673
  const safeSize = calculateEffectiveSize(options);
6972
7674
  const projected = positions.map((p) => project(p[0], p[1]));
6973
7675
  const ringPts = projected.length > 1 && vecMag(vecSub(projected[0], projected[projected.length - 1])) < 1e-6 ? projected.slice(0, -1) : projected;
6974
- const boundaryInput = smooth ? closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)) : [...ringPts, ringPts[0]];
7676
+ const boundaryInput = smooth ? evenlySpacePath(closedCatmullRom(ringPts, normalizeSmoothResolution$2(smoothResolution, DEFAULT_FORTIFIED_AREA_OPTIONS.smoothResolution)), 2 * safeSize) : [...ringPts, ringPts[0]];
6975
7677
  const boundaryPoints = generateFortifiedPoints(boundaryInput, safeSize);
6976
7678
  if (boundaryPoints.length > 0) {
6977
7679
  const firstPt = boundaryPoints[0];
@@ -7013,10 +7715,7 @@ const FORTIFIED_AREA = defineControlMeasure({
7013
7715
  [-1, .5]
7014
7716
  ],
7015
7717
  textAmplifiers: { T: "TANGO" },
7016
- options: {
7017
- labelSizePixels: 12,
7018
- labelSizeResolution: 8
7019
- }
7718
+ options: { labelSize: 96 }
7020
7719
  }
7021
7720
  });
7022
7721
  //#endregion
@@ -7199,14 +7898,10 @@ const JOINT_TACTICAL_ACTION_AREA = defineControlMeasure({
7199
7898
  ],
7200
7899
  textAmplifiers: {
7201
7900
  T: "02",
7202
- N: "ENY",
7203
7901
  W: "051030Z",
7204
7902
  W1: "051600Z"
7205
7903
  },
7206
- options: {
7207
- labelSizePixels: 12,
7208
- labelSizeResolution: 8
7209
- }
7904
+ options: { labelSize: 96 }
7210
7905
  }
7211
7906
  });
7212
7907
  //#endregion
@@ -7256,10 +7951,7 @@ const LANDING_ZONE = defineControlMeasure({
7256
7951
  [-1, .5]
7257
7952
  ],
7258
7953
  textAmplifiers: { T: "SILVER" },
7259
- options: {
7260
- labelSizePixels: 12,
7261
- labelSizeResolution: 8
7262
- }
7954
+ options: { labelSize: 96 }
7263
7955
  }
7264
7956
  });
7265
7957
  //#endregion
@@ -7779,10 +8471,145 @@ const PICKUP_ZONE = defineControlMeasure({
7779
8471
  [-1, .5]
7780
8472
  ],
7781
8473
  textAmplifiers: { T: "WOLF" },
7782
- options: {
7783
- labelSizePixels: 12,
7784
- labelSizeResolution: 8
7785
- }
8474
+ options: { labelSize: 96 }
8475
+ }
8476
+ });
8477
+ //#endregion
8478
+ //#region src/generators/cm15-maneuver-areas/assaultPosition.ts
8479
+ /** Default options for the Assault position control measure. */
8480
+ const DEFAULT_ASSAULT_POSITION_OPTIONS = {
8481
+ smooth: false,
8482
+ smoothResolution: 16
8483
+ };
8484
+ const ASSAULT_POSITION_METADATA = {
8485
+ id: "assault-position",
8486
+ name: "Assault position",
8487
+ description: "That position between the line of departure and the objective in an attack from which forces assault the objective. Ideally, it is the last covered and concealed position before reaching the objective.",
8488
+ entity: "Maneuver Areas",
8489
+ entityType: "Assault Position",
8490
+ value: "151500",
8491
+ minCoordinates: 3,
8492
+ geometry: "area",
8493
+ geometryTypes: ["Polygon", "Point"],
8494
+ drawRule: "Area1",
8495
+ capturesLabelSize: true,
8496
+ params: SMOOTH_AREA_PARAMS,
8497
+ textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
8498
+ };
8499
+ /**
8500
+ * Creates an Assault position control measure: a closed area through
8501
+ * `positions` labeled at its center with "ASLT "-prefixed `T` (Field T) —
8502
+ * the sole label row, per {@link labeledAreaFeatures}.
8503
+ */
8504
+ function createAssaultPosition(positions, options = {}, textAmplifiers = {}) {
8505
+ return labeledAreaFeatures(positions, options, { name: composeAreaName("ASLT", textAmplifiers.T) });
8506
+ }
8507
+ const ASSAULT_POSITION = defineControlMeasure({
8508
+ metadata: ASSAULT_POSITION_METADATA,
8509
+ generator: createAssaultPosition,
8510
+ defaultOptions: DEFAULT_ASSAULT_POSITION_OPTIONS,
8511
+ rule: area1DrawRule,
8512
+ previewSample: {
8513
+ controlPoints: [
8514
+ [-1, -.5],
8515
+ [1, -.5],
8516
+ [1, .5],
8517
+ [-1, .5]
8518
+ ],
8519
+ textAmplifiers: { T: "DANUBE" },
8520
+ options: { labelSize: 96 }
8521
+ }
8522
+ });
8523
+ //#endregion
8524
+ //#region src/generators/cm15-maneuver-areas/attackPosition.ts
8525
+ /** Default options for the Attack position control measure. */
8526
+ const DEFAULT_ATTACK_POSITION_OPTIONS = {
8527
+ smooth: false,
8528
+ smoothResolution: 16
8529
+ };
8530
+ const ATTACK_POSITION_METADATA = {
8531
+ id: "attack-position",
8532
+ name: "Attack position",
8533
+ description: "The last position occupied by the assault echelon before crossing the start line/line of departure.",
8534
+ entity: "Maneuver Areas",
8535
+ entityType: "Attack Position",
8536
+ value: "151600",
8537
+ minCoordinates: 3,
8538
+ geometry: "area",
8539
+ geometryTypes: ["Polygon", "Point"],
8540
+ drawRule: "Area1",
8541
+ capturesLabelSize: true,
8542
+ params: SMOOTH_AREA_PARAMS,
8543
+ textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
8544
+ };
8545
+ /**
8546
+ * Creates an Attack position control measure: a closed area through
8547
+ * `positions` labeled at its center with "ATK "-prefixed `T` (Field T) —
8548
+ * the sole label row, per {@link labeledAreaFeatures}.
8549
+ */
8550
+ function createAttackPosition(positions, options = {}, textAmplifiers = {}) {
8551
+ return labeledAreaFeatures(positions, options, { name: composeAreaName("ATK", textAmplifiers.T) });
8552
+ }
8553
+ const ATTACK_POSITION = defineControlMeasure({
8554
+ metadata: ATTACK_POSITION_METADATA,
8555
+ generator: createAttackPosition,
8556
+ defaultOptions: DEFAULT_ATTACK_POSITION_OPTIONS,
8557
+ rule: area1DrawRule,
8558
+ previewSample: {
8559
+ controlPoints: [
8560
+ [-1, -.5],
8561
+ [1, -.5],
8562
+ [1, .5],
8563
+ [-1, .5]
8564
+ ],
8565
+ textAmplifiers: { T: "NILE" },
8566
+ options: { labelSize: 96 }
8567
+ }
8568
+ });
8569
+ //#endregion
8570
+ //#region src/generators/cm15-maneuver-areas/objectiveArea.ts
8571
+ /** Default options for the Objective area control measure. */
8572
+ const DEFAULT_OBJECTIVE_AREA_OPTIONS = {
8573
+ smooth: false,
8574
+ smoothResolution: 16
8575
+ };
8576
+ const OBJECTIVE_AREA_METADATA = {
8577
+ id: "objective-area",
8578
+ name: "Objective area",
8579
+ description: "A defined geographical area within which is located an objective to be captured or reached by the military forces. This area is defined by competent authority for purposes of command and control.",
8580
+ entity: "Maneuver Areas",
8581
+ entityType: "Objective Area",
8582
+ value: "151700",
8583
+ minCoordinates: 3,
8584
+ geometry: "area",
8585
+ geometryTypes: ["Polygon", "Point"],
8586
+ drawRule: "Area1",
8587
+ capturesLabelSize: true,
8588
+ params: SMOOTH_AREA_PARAMS,
8589
+ textAmplifiers: [AREA_DESIGNATION_ONLY_AMPLIFIER]
8590
+ };
8591
+ /**
8592
+ * Creates an Objective area control measure: a closed area through
8593
+ * `positions` labeled at its center with "OBJ "-prefixed `T` (Field T) —
8594
+ * the sole label row, per {@link labeledAreaFeatures}.
8595
+ */
8596
+ function createObjectiveArea(positions, options = {}, textAmplifiers = {}) {
8597
+ return labeledAreaFeatures(positions, options, { name: composeAreaName("OBJ", textAmplifiers.T) });
8598
+ }
8599
+ const OBJECTIVE_AREA = defineControlMeasure({
8600
+ metadata: OBJECTIVE_AREA_METADATA,
8601
+ generator: createObjectiveArea,
8602
+ defaultOptions: DEFAULT_OBJECTIVE_AREA_OPTIONS,
8603
+ rule: area1DrawRule,
8604
+ previewSample: {
8605
+ controlPoints: [
8606
+ [-1, -.5],
8607
+ [1, -.5],
8608
+ [1, .5],
8609
+ [-1, .5]
8610
+ ],
8611
+ textAmplifiers: { T: "FIVE" },
8612
+ options: { labelSize: 96 }
7786
8613
  }
7787
8614
  });
7788
8615
  //#endregion
@@ -8056,70 +8883,7 @@ const STRONG_POINT_METADATA = {
8056
8883
  ],
8057
8884
  drawRule: "Area1",
8058
8885
  capturesLabelSize: true,
8059
- params: [
8060
- {
8061
- key: "smooth",
8062
- label: "Smooth",
8063
- description: "Round the area's corners by curving the boundary through the control points.",
8064
- type: "boolean"
8065
- },
8066
- {
8067
- key: "smoothResolution",
8068
- label: "Smooth resolution",
8069
- description: "Number of samples per segment when smooth mode is enabled.",
8070
- type: "number",
8071
- min: 2,
8072
- max: 64,
8073
- step: 1,
8074
- visibleWhen: (opts) => Boolean(opts.smooth)
8075
- },
8076
- {
8077
- key: "echelon",
8078
- label: "Echelon",
8079
- description: "Field B unit-size amplifier, drawn on the boundary.",
8080
- type: "enum",
8081
- options: ECHELON_PARAM_OPTIONS
8082
- },
8083
- {
8084
- key: "echelonSizePixels",
8085
- label: "Echelon size",
8086
- description: "Echelon glyph height in screen pixels.",
8087
- type: "number",
8088
- min: 8,
8089
- max: 48,
8090
- step: 1,
8091
- unit: "px"
8092
- },
8093
- {
8094
- key: "echelonSize",
8095
- label: "Echelon size",
8096
- description: "Echelon glyph height in meters.",
8097
- type: "number",
8098
- min: 50,
8099
- max: 2e4,
8100
- step: 50,
8101
- unit: "m"
8102
- },
8103
- {
8104
- key: "echelonPadding",
8105
- label: "Echelon padding",
8106
- description: "Gap on each side of the echelon, as a ratio of its height.",
8107
- type: "number",
8108
- min: 0,
8109
- max: 2,
8110
- step: .05
8111
- },
8112
- {
8113
- key: "echelonPosition",
8114
- label: "Echelon position",
8115
- description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
8116
- type: "number",
8117
- min: -.5,
8118
- max: .5,
8119
- step: .01
8120
- },
8121
- AREA_LABEL_PADDING_PARAM
8122
- ],
8886
+ params: ECHELON_AREA_PARAMS,
8123
8887
  textAmplifiers: [AREA_DESIGNATION_AMPLIFIER]
8124
8888
  };
8125
8889
  /**
@@ -8227,12 +8991,121 @@ const STRONG_POINT = defineControlMeasure({
8227
8991
  options: {
8228
8992
  echelonSize: 200,
8229
8993
  smooth: true,
8230
- labelSizePixels: 12,
8231
- labelSizeResolution: 8
8994
+ labelSize: 96
8232
8995
  }
8233
8996
  }
8234
8997
  });
8235
8998
  //#endregion
8999
+ //#region src/generators/cm15-maneuver-areas/submarineActionArea.ts
9000
+ /** Default options for the Submarine action area control measure. */
9001
+ const DEFAULT_SUBMARINE_ACTION_AREA_OPTIONS = {
9002
+ smooth: false,
9003
+ smoothResolution: 16
9004
+ };
9005
+ const SUBMARINE_ACTION_AREA_METADATA = {
9006
+ id: "submarine-action-area",
9007
+ name: "Submarine action area",
9008
+ description: "An area, including the water space and airspace above it, reserved for submarine operations.",
9009
+ entity: "Maneuver Areas",
9010
+ entityType: "Action Area",
9011
+ entitySubtype: "Submarine Action Area",
9012
+ value: "150502",
9013
+ minCoordinates: 3,
9014
+ geometry: "area",
9015
+ geometryTypes: [
9016
+ "Polygon",
9017
+ "MultiLineString",
9018
+ "Point"
9019
+ ],
9020
+ drawRule: "Area1",
9021
+ capturesLabelSize: true,
9022
+ params: LABELED_AREA_PARAMS,
9023
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO
9024
+ };
9025
+ /**
9026
+ * Creates a Submarine action area control measure: a closed area through
9027
+ * `positions` labeled at its center with "SAA -"-prefixed `T` (Field T) plus
9028
+ * `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
9029
+ */
9030
+ function createSubmarineActionArea(positions, options = {}, textAmplifiers = {}) {
9031
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SAA -", ""));
9032
+ }
9033
+ const SUBMARINE_ACTION_AREA = defineControlMeasure({
9034
+ metadata: SUBMARINE_ACTION_AREA_METADATA,
9035
+ generator: createSubmarineActionArea,
9036
+ defaultOptions: DEFAULT_SUBMARINE_ACTION_AREA_OPTIONS,
9037
+ rule: area1DrawRule,
9038
+ previewSample: {
9039
+ controlPoints: [
9040
+ [-1, -.5],
9041
+ [1, -.5],
9042
+ [1, .5],
9043
+ [-1, .5]
9044
+ ],
9045
+ textAmplifiers: {
9046
+ T: "02",
9047
+ W: "051030Z",
9048
+ W1: "051600Z"
9049
+ },
9050
+ options: { labelSize: 96 }
9051
+ }
9052
+ });
9053
+ //#endregion
9054
+ //#region src/generators/cm15-maneuver-areas/submarineGeneratedActionArea.ts
9055
+ /** Default options for the Submarine-generated action area control measure. */
9056
+ const DEFAULT_SUBMARINE_GENERATED_ACTION_AREA_OPTIONS = {
9057
+ smooth: false,
9058
+ smoothResolution: 16
9059
+ };
9060
+ const SUBMARINE_GENERATED_ACTION_AREA_METADATA = {
9061
+ id: "submarine-generated-action-area",
9062
+ name: "Submarine-generated action area",
9063
+ description: "An action area generated around a submarine's reported position, reserved for its operations.",
9064
+ entity: "Maneuver Areas",
9065
+ entityType: "Action Area",
9066
+ entitySubtype: "Submarine-Generated Action Area",
9067
+ value: "150503",
9068
+ minCoordinates: 3,
9069
+ geometry: "area",
9070
+ geometryTypes: [
9071
+ "Polygon",
9072
+ "MultiLineString",
9073
+ "Point"
9074
+ ],
9075
+ drawRule: "Area1",
9076
+ capturesLabelSize: true,
9077
+ params: LABELED_AREA_PARAMS,
9078
+ textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO
9079
+ };
9080
+ /**
9081
+ * Creates a Submarine-generated action area control measure: a closed area
9082
+ * through `positions` labeled at its center with "SGSA -"-prefixed `T`
9083
+ * (Field T) plus `W`/`W1`/`N` amplifier rows, per {@link labeledAreaFeatures}.
9084
+ */
9085
+ function createSubmarineGeneratedActionArea(positions, options = {}, textAmplifiers = {}) {
9086
+ return labeledAreaFeatures(positions, options, composeAreaLabelTexts(textAmplifiers, "SGSA -", ""));
9087
+ }
9088
+ const SUBMARINE_GENERATED_ACTION_AREA = defineControlMeasure({
9089
+ metadata: SUBMARINE_GENERATED_ACTION_AREA_METADATA,
9090
+ generator: createSubmarineGeneratedActionArea,
9091
+ defaultOptions: DEFAULT_SUBMARINE_GENERATED_ACTION_AREA_OPTIONS,
9092
+ rule: area1DrawRule,
9093
+ previewSample: {
9094
+ controlPoints: [
9095
+ [-1, -.5],
9096
+ [1, -.5],
9097
+ [1, .5],
9098
+ [-1, .5]
9099
+ ],
9100
+ textAmplifiers: {
9101
+ T: "02",
9102
+ W: "051030Z",
9103
+ W1: "051600Z"
9104
+ },
9105
+ options: { labelSize: 96 }
9106
+ }
9107
+ });
9108
+ //#endregion
8236
9109
  //#region src/generators/cm15-maneuver-areas/supportByFire.ts
8237
9110
  const DEFAULT_SUPPORT_BY_FIRE_OPTIONS = {
8238
9111
  arrowheadWidthRatio: .3,
@@ -8571,11 +9444,25 @@ const DEFINITIONS = {
8571
9444
  "final-protective-fire-right": FINAL_PROTECTIVE_FIRE_RIGHT,
8572
9445
  "search-area": SEARCH_AREA,
8573
9446
  encirclement: ENCIRCLEMENT,
9447
+ area: AREA,
8574
9448
  "assembly-area": ASSEMBLY_AREA,
8575
9449
  "joint-tactical-action-area": JOINT_TACTICAL_ACTION_AREA,
9450
+ "submarine-action-area": SUBMARINE_ACTION_AREA,
9451
+ "submarine-generated-action-area": SUBMARINE_GENERATED_ACTION_AREA,
9452
+ "drop-zone": DROP_ZONE,
9453
+ "extraction-zone": EXTRACTION_ZONE,
8576
9454
  "landing-zone": LANDING_ZONE,
8577
9455
  "pickup-zone": PICKUP_ZONE,
9456
+ "assault-position": ASSAULT_POSITION,
9457
+ "attack-position": ATTACK_POSITION,
9458
+ "objective-area": OBJECTIVE_AREA,
8578
9459
  "area-of-operations": AREA_OF_OPERATIONS,
9460
+ "named-area-of-interest": NAMED_AREA_OF_INTEREST,
9461
+ "target-area-of-interest": TARGET_AREA_OF_INTEREST,
9462
+ "airfield-zone": AIRFIELD_ZONE,
9463
+ "base-camp": BASE_CAMP,
9464
+ "guerrilla-base": GUERRILLA_BASE,
9465
+ "generic-c2-area": GENERIC_C2_AREA,
8579
9466
  "main-attack": MAIN_ATTACK,
8580
9467
  "supporting-attack": SUPPORTING_ATTACK,
8581
9468
  "classic-arrow": CLASSIC_ARROW,
@@ -8854,7 +9741,16 @@ function normalizeTextAmplifiers(input) {
8854
9741
  * `properties.style` when non-empty.
8855
9742
  */
8856
9743
  function renderControlMeasure(cm, opts) {
8857
- const features = dispatchControlMeasure(cm, opts).features.map((feature, index) => normalizeFeature(cm.id, feature, index, cm.style, opts?.graphicsStyle));
9744
+ const raw = dispatchControlMeasure(cm, opts);
9745
+ const generatedLabelOrdinals = /* @__PURE__ */ new Map();
9746
+ const features = raw.features.map((feature, index) => {
9747
+ const sourceProps = feature.properties ?? {};
9748
+ const part = typeof sourceProps.part === "string" && sourceProps.part.length > 0 ? sourceProps.part : "main";
9749
+ const isGeneratedLabel = sourceProps.labelPlacement !== false && !(typeof sourceProps.amplifierField === "string" && canonicalTextAmplifierKey(sourceProps.amplifierField)) && !(typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) && typeof sourceProps.text === "string" && feature.geometry.type === "Point";
9750
+ const generatedLabelOrdinal = isGeneratedLabel ? generatedLabelOrdinals.get(part) ?? 0 : index;
9751
+ if (isGeneratedLabel) generatedLabelOrdinals.set(part, generatedLabelOrdinal + 1);
9752
+ return normalizeFeature(cm.id, feature, index, generatedLabelOrdinal, cm.style, opts?.graphicsStyle, cm.amplifierPlacements);
9753
+ });
8858
9754
  const bbox = computeBBox(features);
8859
9755
  return {
8860
9756
  type: "FeatureCollection",
@@ -8886,7 +9782,20 @@ function validateInputContract(points, metadata, mode) {
8886
9782
  if (points.some((point) => !isValidPosition(point))) return reportValidationIssue(mode, `"${metadata.id}" control points must contain finite [lon, lat] values.`);
8887
9783
  return true;
8888
9784
  }
8889
- function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
9785
+ /**
9786
+ * The `amplifierPlacements` key that repositions this feature, if any: the
9787
+ * canonical amplifier field when the generator declared one, an explicit
9788
+ * `labelPlacementKey`, or a synthesized `label:<part>:<ordinal>` key for any
9789
+ * other point label — unless the generator opted out via
9790
+ * `labelPlacement: false`.
9791
+ */
9792
+ function resolveLabelPlacementKey(sourceProps, amplifierField, text, geometryType, part, generatedLabelOrdinal) {
9793
+ if (sourceProps.labelPlacement === false) return void 0;
9794
+ if (amplifierField) return amplifierField;
9795
+ if (typeof sourceProps.labelPlacementKey === "string" && sourceProps.labelPlacementKey.length > 0) return sourceProps.labelPlacementKey;
9796
+ if (text !== void 0 && geometryType === "Point") return `label:${part}:${generatedLabelOrdinal}`;
9797
+ }
9798
+ function normalizeFeature(id, feature, index, generatedLabelOrdinal, measureStyle, graphicsStyle, amplifierPlacements) {
8890
9799
  const sourceProps = feature.properties ?? {};
8891
9800
  const part = typeof sourceProps.part === "string" && sourceProps.part.length > 0 ? sourceProps.part : "main";
8892
9801
  const generatorStyle = typeof sourceProps.style === "object" && sourceProps.style !== null ? sourceProps.style : void 0;
@@ -8895,9 +9804,15 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
8895
9804
  const text = typeof sourceProps.text === "string" ? sourceProps.text : void 0;
8896
9805
  const rotation = typeof sourceProps.rotation === "number" ? sourceProps.rotation : void 0;
8897
9806
  const textSizePixels = typeof sourceProps.textSizePixels === "number" ? sourceProps.textSizePixels : void 0;
8898
- const textSizeZoom = typeof sourceProps.textSizeZoom === "number" ? sourceProps.textSizeZoom : void 0;
8899
- const textSizeResolution = typeof sourceProps.textSizeResolution === "number" ? sourceProps.textSizeResolution : void 0;
9807
+ const textSizeMeters = typeof sourceProps.textSizeMeters === "number" ? sourceProps.textSizeMeters : void 0;
8900
9808
  const textAnchor = sourceProps.textAnchor === "start" || sourceProps.textAnchor === "end" ? sourceProps.textAnchor : void 0;
9809
+ const amplifierField = typeof sourceProps.amplifierField === "string" ? canonicalTextAmplifierKey(sourceProps.amplifierField) : void 0;
9810
+ const labelPlacementKey = resolveLabelPlacementKey(sourceProps, amplifierField, text, feature.geometry.type, part, generatedLabelOrdinal);
9811
+ const placement = labelPlacementKey ? amplifierPlacements?.[labelPlacementKey] : void 0;
9812
+ const geometry = placement && isValidPosition(placement) && feature.geometry.type === "Point" ? {
9813
+ ...feature.geometry,
9814
+ coordinates: [...placement]
9815
+ } : feature.geometry;
8901
9816
  return {
8902
9817
  type: "Feature",
8903
9818
  id: `${id}:${part}:${index}`,
@@ -8908,11 +9823,12 @@ function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
8908
9823
  ...text !== void 0 ? { text } : {},
8909
9824
  ...rotation !== void 0 ? { rotation } : {},
8910
9825
  ...textAnchor !== void 0 ? { textAnchor } : {},
9826
+ ...amplifierField !== void 0 ? { amplifierField } : {},
9827
+ ...labelPlacementKey !== void 0 ? { labelPlacementKey } : {},
8911
9828
  ...textSizePixels !== void 0 ? { textSizePixels } : {},
8912
- ...textSizeZoom !== void 0 ? { textSizeZoom } : {},
8913
- ...textSizeResolution !== void 0 ? { textSizeResolution } : {}
9829
+ ...textSizeMeters !== void 0 ? { textSizeMeters } : {}
8914
9830
  },
8915
- geometry: feature.geometry
9831
+ geometry
8916
9832
  };
8917
9833
  }
8918
9834
  function computeBBox(features) {