@orbat-mapper/control-measures 0.11.0 → 0.12.1

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.
@@ -130,15 +130,27 @@ const destinationPoint = (origin, distance, bearing) => {
130
130
  //#endregion
131
131
  //#region src/internal/vector-utils.ts
132
132
  /**
133
- * Offsets a polyline using Miter Joins or Round Joins.
133
+ * Offsets a polyline to both sides at once, using Miter Joins or Round Joins.
134
+ * `offsets` carries one half-width per vertex, so the outline may taper along
135
+ * its centerline; both sides read the same array rather than materialising a
136
+ * negated copy per geometry pass.
134
137
  */
135
- function offsetPolyline(points, offset, rounded = false, segments = 5) {
138
+ function offsetPolylineSides(points, offsets, rounded = false, segments = 5) {
139
+ return {
140
+ left: offsetPolylineWithOffsets(points, offsets, rounded, segments, 1),
141
+ right: offsetPolylineWithOffsets(points, offsets, rounded, segments, -1)
142
+ };
143
+ }
144
+ /** One side of {@link offsetPolylineSides}; `sign` picks which. */
145
+ function offsetPolylineWithOffsets(points, offsets, rounded, segments, sign) {
136
146
  if (points.length < 2) return points;
147
+ if (offsets.length !== points.length) return points;
137
148
  const result = [];
138
149
  const N = points.length;
139
150
  for (let i = 0; i < N; i++) {
140
151
  const p = points[i];
141
152
  if (!p) continue;
153
+ const offset = offsets[i] * sign;
142
154
  if (i === 0) {
143
155
  const next = points[i + 1];
144
156
  if (next) {
@@ -195,6 +207,17 @@ function offsetPolyline(points, offset, rounded = false, segments = 5) {
195
207
  }
196
208
  return result;
197
209
  }
210
+ /** Linearly interpolates values by distance along a polyline, not vertex count. */
211
+ function interpolatePolylineValues(points, startValue, endValue) {
212
+ const distances = [0];
213
+ for (let i = 1; i < points.length; i++) distances.push(distances[i - 1] + vecMag(vecSub(points[i], points[i - 1])));
214
+ const totalLength = distances.at(-1) ?? 0;
215
+ if (totalLength < 1e-6) return points.map(() => endValue);
216
+ return distances.map((distance) => {
217
+ const progress = distance / totalLength;
218
+ return startValue + (endValue - startValue) * progress;
219
+ });
220
+ }
198
221
  function vecAdd(a, b) {
199
222
  return [a[0] + b[0], a[1] + b[1]];
200
223
  }
@@ -248,9 +271,81 @@ function lineIntersection(p1, p2, p3, p4) {
248
271
  }
249
272
  //#endregion
250
273
  //#region src/attack-utils.ts
251
- const DEFAULT_SHAFT_WIDTH_RATIO = .6;
274
+ const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
275
+ const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
252
276
  const SHAFT_MIN_RATIO = .1;
253
277
  const SHAFT_MAX_RATIO = .9;
278
+ const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
279
+ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
280
+ /**
281
+ * Creates the definition-owned rear-/shaft-width handle pair shared by every
282
+ * variable-width arrow body. Callers supply only how their own geometry maps
283
+ * onto {@link WidthHandleGeometry}; placement (the two ends of the left edge)
284
+ * and the drag math (perpendicular distance from the dragged point to the
285
+ * matching centerline segment, over the reference half-width) live here once.
286
+ */
287
+ function createWidthOptionHandles(config) {
288
+ return {
289
+ get(controlPoints, options) {
290
+ const geometry = config.geometry(controlPoints, options);
291
+ const rearLeft = geometry?.leftEdge[0];
292
+ const neckLeft = geometry?.leftEdge.at(-1);
293
+ if (!rearLeft || !neckLeft) return [];
294
+ return [{
295
+ id: REAR_WIDTH_OPTION_HANDLE_ID,
296
+ position: unproject(rearLeft[0], rearLeft[1])
297
+ }, {
298
+ id: SHAFT_WIDTH_OPTION_HANDLE_ID,
299
+ position: unproject(neckLeft[0], neckLeft[1])
300
+ }];
301
+ },
302
+ drag({ controlPoints, options, handleId, position }) {
303
+ const geometry = config.geometry(controlPoints, options);
304
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
305
+ const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
306
+ if (!rear && !(handleId === "shaft-width")) return void 0;
307
+ const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
308
+ const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
309
+ if (!from || !to) return void 0;
310
+ const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
311
+ return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
312
+ }
313
+ };
314
+ }
315
+ /**
316
+ * The width handles for attack bodies built by {@link processAttackGeometry},
317
+ * whose ratios are fractions of the arrowhead half-width. A coordinate resolver
318
+ * lets derivative measures (Counterattack by Fire) map their authored Axis1
319
+ * points to the body coordinates first.
320
+ */
321
+ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) => points) {
322
+ return createWidthOptionHandles({
323
+ geometry(controlPoints, options) {
324
+ const coordinates = resolveCoordinates(controlPoints);
325
+ if (!coordinates) return null;
326
+ const { geometry } = processAttackGeometry(coordinates, options);
327
+ const outerLeft = geometry?.headRing[0];
328
+ if (!geometry || !outerLeft) return null;
329
+ return {
330
+ spine: geometry.shaftCenterline,
331
+ leftEdge: geometry.shaftLeft,
332
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
333
+ };
334
+ },
335
+ minRatio: SHAFT_MIN_RATIO,
336
+ maxRearRatio: 3,
337
+ maxShaftRatio: SHAFT_MAX_RATIO
338
+ });
339
+ }
340
+ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles();
341
+ /**
342
+ * The single home of the "rear inherits shaft" rule: an omitted `rearWidthRatio`
343
+ * leaves the body parallel by matching the effective shaft ratio. Every
344
+ * variable-width body resolves the option through here so the rule cannot drift.
345
+ */
346
+ function resolveRearWidthRatio(options, shaftRatio) {
347
+ return options.rearWidthRatio ?? shaftRatio;
348
+ }
254
349
  /**
255
350
  * Processes input coordinates and options to generate the core symbol geometry.
256
351
  * This handles validation, default values, projection, and geometry calculation.
@@ -259,6 +354,7 @@ function processAttackGeometry(coordinates, options = {}) {
259
354
  const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
260
355
  const smooth = options.smooth ?? false;
261
356
  const smoothResolution = options.smoothResolution ?? 5;
357
+ const rearRatio = clamp(resolveRearWidthRatio(options, shaftRatio), SHAFT_MIN_RATIO, 3);
262
358
  const points = coordinates.map((c) => project(c[0], c[1]));
263
359
  const numPoints = points.length;
264
360
  const ptTip = points[0];
@@ -268,9 +364,9 @@ function processAttackGeometry(coordinates, options = {}) {
268
364
  const p = points[i];
269
365
  if (p) spinePoints.push(p);
270
366
  }
271
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, smooth, smoothResolution) };
367
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
272
368
  }
273
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoothResolution) {
369
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
274
370
  const initialNeck = spine[spine.length - 1];
275
371
  if (!initialNeck) return null;
276
372
  const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
@@ -323,10 +419,11 @@ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, smooth, smoo
323
419
  outerLeft
324
420
  ];
325
421
  const fullSpine = [...remainingSpine, shaftEndCenter];
422
+ const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
326
423
  return {
327
424
  shaftCenterline: fullSpine,
328
- shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, smooth, smoothResolution),
329
- shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, smooth, smoothResolution),
425
+ shaftLeft,
426
+ shaftRight,
330
427
  headRing,
331
428
  ptTip,
332
429
  ptNeck,
@@ -1726,6 +1823,16 @@ const ATTACK_SHAFT_PARAMS = [
1726
1823
  max: 1,
1727
1824
  step: .05
1728
1825
  },
1826
+ {
1827
+ key: "rearWidthRatio",
1828
+ presentationTier: "advanced",
1829
+ label: "Rear width",
1830
+ description: "Width at the rear of the shaft as a ratio of the arrowhead width.",
1831
+ type: "number",
1832
+ min: .1,
1833
+ max: 2,
1834
+ step: .05
1835
+ },
1729
1836
  {
1730
1837
  key: "smooth",
1731
1838
  label: "Smooth",
@@ -1845,9 +1952,19 @@ const AREA_LABEL_PADDING_PARAM = {
1845
1952
  max: 2,
1846
1953
  step: .05
1847
1954
  };
1955
+ const MULTILINE_AREA_LABEL_PARAM = {
1956
+ key: "multilineLabel",
1957
+ label: "Multiline label",
1958
+ description: "Place the doctrinal label above Field T on separate centered lines",
1959
+ type: "boolean"
1960
+ };
1848
1961
  const LABELED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, AREA_LABEL_PADDING_PARAM];
1849
- const ECHELON_AREA_PARAMS = [
1962
+ const PREFIXED_LABELED_AREA_PARAMS = [
1850
1963
  ...AREA_SMOOTH_PARAMS,
1964
+ MULTILINE_AREA_LABEL_PARAM,
1965
+ AREA_LABEL_PADDING_PARAM
1966
+ ];
1967
+ const ECHELON_GLYPH_PARAMS = [
1851
1968
  {
1852
1969
  key: "echelon",
1853
1970
  label: "Echelon",
@@ -1896,7 +2013,17 @@ const ECHELON_AREA_PARAMS = [
1896
2013
  min: -.5,
1897
2014
  max: .5,
1898
2015
  step: .01
1899
- },
2016
+ }
2017
+ ];
2018
+ const ECHELON_AREA_PARAMS = [
2019
+ ...AREA_SMOOTH_PARAMS,
2020
+ ...ECHELON_GLYPH_PARAMS,
2021
+ AREA_LABEL_PADDING_PARAM
2022
+ ];
2023
+ const PREFIXED_ECHELON_AREA_PARAMS = [
2024
+ ...AREA_SMOOTH_PARAMS,
2025
+ MULTILINE_AREA_LABEL_PARAM,
2026
+ ...ECHELON_GLYPH_PARAMS,
1900
2027
  AREA_LABEL_PADDING_PARAM
1901
2028
  ];
1902
2029
  const AREA_DESIGNATION_AMPLIFIER = {
@@ -1954,7 +2081,8 @@ const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d)
1954
2081
  //#endregion
1955
2082
  //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
1956
2083
  const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
1957
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
2084
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
2085
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
1958
2086
  smooth: false,
1959
2087
  smoothResolution: 5
1960
2088
  };
@@ -2019,7 +2147,8 @@ const AIRBORNE_ATTACK = defineControlMeasure({
2019
2147
  metadata: AIRBORNE_ATTACK_METADATA,
2020
2148
  generator: createAirborneAttack,
2021
2149
  defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
2022
- rule: axis1DrawRule
2150
+ rule: axis1DrawRule,
2151
+ optionHandles: variableWidthAttackOptionHandles
2023
2152
  });
2024
2153
  /**
2025
2154
  * Reacts to an **input-contract** violation according to `mode`: `throw`
@@ -3897,8 +4026,10 @@ function composeAreaName(prefix, designation, separator = " ") {
3897
4026
  * which differ only in `prefix` (and JTAA's empty `separator`).
3898
4027
  */
3899
4028
  function composeAreaLabelTexts(textAmplifiers, prefix = "", separator = " ") {
4029
+ const designation = textAmplifiers.T?.trim() ?? "";
3900
4030
  return {
3901
- name: composeAreaName(prefix, textAmplifiers.T, separator),
4031
+ name: composeAreaName(prefix, designation, separator),
4032
+ multilineName: prefix && designation ? `${prefix}\n${designation}` : void 0,
3902
4033
  additionalInformation: textAmplifiers.H,
3903
4034
  dtgStart: textAmplifiers.W,
3904
4035
  dtgEnd: textAmplifiers.W1,
@@ -4134,19 +4265,41 @@ function pushAreaLabels(out, ringVerts, texts, options = {}, amplifierPlacements
4134
4265
  const padding = Math.max(0, options.labelPadding ?? 0);
4135
4266
  const maskRingVerts = maskBoundary?.ringVerts ?? ringVerts;
4136
4267
  const [cx, cy] = ringLabelAnchor(ringVerts);
4268
+ const multilineName = options.multilineLabel ? texts.multilineName : void 0;
4137
4269
  const rows = [
4138
- [texts.name, "T"],
4139
- [texts.additionalInformation, "H"],
4140
- [texts.dtgStart, "W"],
4141
- [texts.dtgEnd, "W1"]
4142
- ].flatMap(([text, field]) => text ? [{
4270
+ [
4271
+ multilineName ?? texts.name,
4272
+ "T",
4273
+ multilineName ? multilineName.split("\n").length : 1
4274
+ ],
4275
+ [
4276
+ texts.additionalInformation,
4277
+ "H",
4278
+ 1
4279
+ ],
4280
+ [
4281
+ texts.dtgStart,
4282
+ "W",
4283
+ 1
4284
+ ],
4285
+ [
4286
+ texts.dtgEnd,
4287
+ "W1",
4288
+ 1
4289
+ ]
4290
+ ].flatMap(([text, field, lineCount]) => text ? [{
4143
4291
  text,
4144
- field
4292
+ field,
4293
+ lineCount
4145
4294
  }] : []);
4146
4295
  if (rows.length > 0) {
4147
4296
  const pitch = resolveLabelOffsetMeters(options, ROW_PITCH_RATIO + padding);
4148
- rows.forEach(({ text, field }, i) => {
4149
- pushLabel(out, [cx, cy + ((rows.length - 1) / 2 - i) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, field);
4297
+ const totalLineCount = rows.reduce((sum, row) => sum + row.lineCount, 0);
4298
+ let precedingLineCount = 0;
4299
+ rows.forEach(({ text, field, lineCount }) => {
4300
+ const rowCenter = precedingLineCount + (lineCount - 1) / 2;
4301
+ pushLabel(out, [cx, cy + ((totalLineCount - 1) / 2 - rowCenter) * pitch], text, HORIZONTAL_LABEL_ROTATION, sizeProps, void 0, field);
4302
+ precedingLineCount += lineCount;
4150
4303
  });
4151
4304
  }
4152
4305
  if (!texts.hostile) return [];
@@ -4552,7 +4705,7 @@ const ENGAGEMENT_AREA_METADATA = {
4552
4705
  },
4553
4706
  drawRule: "Area1",
4554
4707
  capturesLabelSize: true,
4555
- params: LABELED_AREA_PARAMS,
4708
+ params: PREFIXED_LABELED_AREA_PARAMS,
4556
4709
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
4557
4710
  };
4558
4711
  /**
@@ -4819,7 +4972,7 @@ const AREA_OF_OPERATIONS_METADATA = {
4819
4972
  },
4820
4973
  drawRule: "Area1",
4821
4974
  capturesLabelSize: true,
4822
- params: LABELED_AREA_PARAMS,
4975
+ params: PREFIXED_LABELED_AREA_PARAMS,
4823
4976
  textAmplifiers: AREA_TEXT_AMPLIFIERS
4824
4977
  };
4825
4978
  /**
@@ -4843,7 +4996,10 @@ const AREA_OF_OPERATIONS = defineControlMeasure({
4843
4996
  [-1, .5]
4844
4997
  ],
4845
4998
  textAmplifiers: { T: "GREEN" },
4846
- options: { labelSize: 96 }
4999
+ options: {
5000
+ labelSize: 96,
5001
+ multilineLabel: true
5002
+ }
4847
5003
  }
4848
5004
  });
4849
5005
  //#endregion
@@ -4878,7 +5034,7 @@ const NAMED_AREA_OF_INTEREST_METADATA = {
4878
5034
  },
4879
5035
  drawRule: "Area1",
4880
5036
  capturesLabelSize: true,
4881
- params: LABELED_AREA_PARAMS,
5037
+ params: PREFIXED_LABELED_AREA_PARAMS,
4882
5038
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
4883
5039
  };
4884
5040
  /**
@@ -4937,7 +5093,7 @@ const TARGET_AREA_OF_INTEREST_METADATA = {
4937
5093
  },
4938
5094
  drawRule: "Area1",
4939
5095
  capturesLabelSize: true,
4940
- params: LABELED_AREA_PARAMS,
5096
+ params: PREFIXED_LABELED_AREA_PARAMS,
4941
5097
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
4942
5098
  };
4943
5099
  /**
@@ -5189,7 +5345,7 @@ const BASE_CAMP_METADATA = {
5189
5345
  },
5190
5346
  drawRule: "Area1",
5191
5347
  capturesLabelSize: true,
5192
- params: ECHELON_AREA_PARAMS,
5348
+ params: PREFIXED_ECHELON_AREA_PARAMS,
5193
5349
  textAmplifiers: AREA_TEXT_AMPLIFIERS
5194
5350
  };
5195
5351
  /**
@@ -5247,7 +5403,7 @@ const GUERRILLA_BASE_METADATA = {
5247
5403
  },
5248
5404
  drawRule: "Area1",
5249
5405
  capturesLabelSize: true,
5250
- params: ECHELON_AREA_PARAMS,
5406
+ params: PREFIXED_ECHELON_AREA_PARAMS,
5251
5407
  textAmplifiers: AREA_TEXT_AMPLIFIERS
5252
5408
  };
5253
5409
  /**
@@ -5365,7 +5521,7 @@ const ASSEMBLY_AREA_METADATA = {
5365
5521
  },
5366
5522
  drawRule: "Area1",
5367
5523
  capturesLabelSize: true,
5368
- params: LABELED_AREA_PARAMS,
5524
+ params: PREFIXED_LABELED_AREA_PARAMS,
5369
5525
  textAmplifiers: AREA_TEXT_AMPLIFIERS
5370
5526
  };
5371
5527
  /**
@@ -5856,7 +6012,8 @@ const ATTACK_BY_FIRE = defineControlMeasure({
5856
6012
  //#endregion
5857
6013
  //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
5858
6014
  const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
5859
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6015
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
6016
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
5860
6017
  smooth: false,
5861
6018
  smoothResolution: 5,
5862
6019
  symbolHeightRatio: .45,
@@ -6075,7 +6232,8 @@ const ATTACK_HELICOPTER = defineControlMeasure({
6075
6232
  metadata: ATTACK_HELICOPTER_METADATA,
6076
6233
  generator: createAttackHelicopter,
6077
6234
  defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
6078
- rule: axis1DrawRule
6235
+ rule: axis1DrawRule,
6236
+ optionHandles: variableWidthAttackOptionHandles
6079
6237
  });
6080
6238
  //#endregion
6081
6239
  //#region src/generators/cm15-maneuver-areas/battlePosition.ts
@@ -6133,10 +6291,7 @@ function battlePositionAreaFeatures(positions, options, textAmplifiers, context,
6133
6291
  const verts = buildClosedRing(positions, smooth, smoothResolution, DEFAULT_SMOOTH_RESOLUTION$6);
6134
6292
  const { strokes, fills, gaps } = placeEchelon(buildAreaPerimeter(verts), h, echelon, echelonPadding, echelonPosition, echelonAnchor);
6135
6293
  const labelFeatures = [];
6136
- const enyGaps = pushAreaLabels(labelFeatures, verts, {
6137
- name: variant.namePrefix ? composeAreaName(variant.namePrefix, textAmplifiers.T) : textAmplifiers.T,
6138
- hostile: textAmplifiers.N
6139
- }, options, context.amplifierPlacements, void 0, context);
6294
+ const enyGaps = pushAreaLabels(labelFeatures, verts, composeAreaLabelTexts(textAmplifiers, variant.namePrefix), options, context.amplifierPlacements, void 0, context);
6140
6295
  const boundaryCoords = buildGappedLine(verts, [...gaps, ...enyGaps]);
6141
6296
  const features = [];
6142
6297
  if (boundaryCoords.length > 0) features.push({
@@ -6257,7 +6412,7 @@ const BATTLE_POSITION_PREPARED_METADATA = {
6257
6412
  },
6258
6413
  drawRule: "Area1",
6259
6414
  capturesLabelSize: true,
6260
- params: ECHELON_AREA_PARAMS,
6415
+ params: PREFIXED_ECHELON_AREA_PARAMS,
6261
6416
  textAmplifiers: AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS
6262
6417
  };
6263
6418
  /**
@@ -6710,8 +6865,12 @@ const BRIDGE_OR_GAP = defineControlMeasure({
6710
6865
  const DEFAULT_SMOOTH_RESOLUTION$5 = 12;
6711
6866
  const MIN_SMOOTH_RESOLUTION$1 = 2;
6712
6867
  const MAX_SMOOTH_RESOLUTION$1 = 64;
6868
+ const DEFAULT_SHAFT_WIDTH_RATIO = .06;
6869
+ const MIN_SHAFT_WIDTH_RATIO = .01;
6870
+ const MAX_SHAFT_WIDTH_RATIO = .3;
6713
6871
  const DEFAULT_BLOCK_ARROW_OPTIONS = {
6714
- shaftWidthRatio: .06,
6872
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6873
+ rearWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
6715
6874
  arrowheadStyle: "triangle",
6716
6875
  arrowheadWidthRatio: .18,
6717
6876
  arrowheadLengthRatio: .22,
@@ -6743,8 +6902,18 @@ const BLOCK_ARROW_METADATA = {
6743
6902
  label: "Shaft width",
6744
6903
  description: "Shaft band width as a fraction of the total path length",
6745
6904
  type: "number",
6746
- min: .01,
6747
- max: .3,
6905
+ min: MIN_SHAFT_WIDTH_RATIO,
6906
+ max: MAX_SHAFT_WIDTH_RATIO,
6907
+ step: .01
6908
+ },
6909
+ {
6910
+ key: "rearWidthRatio",
6911
+ presentationTier: "advanced",
6912
+ label: "Rear width",
6913
+ description: "Width at the rear of the shaft as a fraction of the total path length",
6914
+ type: "number",
6915
+ min: MIN_SHAFT_WIDTH_RATIO,
6916
+ max: MAX_SHAFT_WIDTH_RATIO,
6748
6917
  step: .01
6749
6918
  },
6750
6919
  {
@@ -6835,25 +7004,42 @@ const BLOCK_ARROW_METADATA = {
6835
7004
  * right side of the shaft.
6836
7005
  */
6837
7006
  function createBlockArrow(coordinates, options = {}) {
6838
- const { shaftWidthRatio, arrowheadStyle, arrowheadWidthRatio, arrowheadLengthRatio, smooth, smoothResolution, filled } = {
6839
- ...DEFAULT_BLOCK_ARROW_OPTIONS,
6840
- ...options
6841
- };
6842
- const axis1 = axis1Geometry(coordinates);
6843
- const axis = arrowAxis(axis1.path);
6844
- if (!axis) return {
7007
+ const resolved = resolveBlockArrowOptions(options);
7008
+ const geometry = calculateBlockArrowGeometry(coordinates, resolved);
7009
+ if (!geometry) return {
6845
7010
  type: "FeatureCollection",
6846
7011
  features: []
6847
7012
  };
7013
+ const { ring } = geometry;
7014
+ return {
7015
+ type: "FeatureCollection",
7016
+ features: [{
7017
+ type: "Feature",
7018
+ properties: {
7019
+ part: "body",
7020
+ fill: resolved.filled
7021
+ },
7022
+ geometry: {
7023
+ type: "Polygon",
7024
+ coordinates: [ring.map((p) => unproject(p[0], p[1]))]
7025
+ }
7026
+ }]
7027
+ };
7028
+ }
7029
+ function calculateBlockArrowGeometry(coordinates, options) {
7030
+ const axis1 = axis1Geometry(coordinates);
7031
+ const axis = arrowAxis(axis1.path);
7032
+ if (!axis) return null;
6848
7033
  const { pts, pathLength, tip, dir, perp, segLength } = axis;
6849
- const requestedHeadLen = axis1.headLength ?? pathLength * arrowheadLengthRatio;
7034
+ const requestedHeadLen = axis1.headLength ?? pathLength * options.arrowheadLengthRatio;
6850
7035
  const headLen = Math.min(requestedHeadLen, segLength * .95);
6851
- const halfShaft = pathLength * shaftWidthRatio / 2;
6852
- const halfHead = axis1.headHalfWidth ?? pathLength * arrowheadWidthRatio / 2;
7036
+ const halfShaft = pathLength * options.shaftWidthRatio / 2;
7037
+ const halfRear = pathLength * options.rearWidthRatio / 2;
7038
+ const halfHead = axis1.headHalfWidth ?? pathLength * options.arrowheadWidthRatio / 2;
6853
7039
  const onAxis = (d, side = 0) => [tip[0] - dir[0] * d + perp[0] * side, tip[1] - dir[1] * d + perp[1] * side];
6854
7040
  let shaftEndDist;
6855
7041
  let headPts;
6856
- switch (arrowheadStyle) {
7042
+ switch (options.arrowheadStyle) {
6857
7043
  case "barbed":
6858
7044
  shaftEndDist = headLen * .55;
6859
7045
  headPts = [
@@ -6911,9 +7097,9 @@ function createBlockArrow(coordinates, options = {}) {
6911
7097
  break;
6912
7098
  }
6913
7099
  let spine = [...pts.slice(0, -1), onAxis(shaftEndDist)];
6914
- if (smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(smoothResolution));
6915
- const leftSide = offsetPolyline(spine, halfShaft);
6916
- const rightSide = offsetPolyline(spine, -halfShaft);
7100
+ if (options.smooth) spine = catmullRom(spine, normalizeSmoothResolution$1(options.smoothResolution));
7101
+ const widths = interpolatePolylineValues(spine, halfRear, halfShaft);
7102
+ const { left: leftSide, right: rightSide } = offsetPolylineSides(spine, widths);
6917
7103
  const ring = [
6918
7104
  ...leftSide,
6919
7105
  ...headPts,
@@ -6921,18 +7107,20 @@ function createBlockArrow(coordinates, options = {}) {
6921
7107
  ];
6922
7108
  ring.push(ring[0]);
6923
7109
  return {
6924
- type: "FeatureCollection",
6925
- features: [{
6926
- type: "Feature",
6927
- properties: {
6928
- part: "body",
6929
- fill: filled
6930
- },
6931
- geometry: {
6932
- type: "Polygon",
6933
- coordinates: [ring.map((p) => unproject(p[0], p[1]))]
6934
- }
6935
- }]
7110
+ ring,
7111
+ spine,
7112
+ leftSide,
7113
+ pathLength
7114
+ };
7115
+ }
7116
+ function resolveBlockArrowOptions(options) {
7117
+ const resolved = {
7118
+ ...DEFAULT_BLOCK_ARROW_OPTIONS,
7119
+ ...options
7120
+ };
7121
+ return {
7122
+ ...resolved,
7123
+ rearWidthRatio: resolveRearWidthRatio(options, resolved.shaftWidthRatio)
6936
7124
  };
6937
7125
  }
6938
7126
  const BLOCK_ARROW = defineControlMeasure({
@@ -6940,6 +7128,20 @@ const BLOCK_ARROW = defineControlMeasure({
6940
7128
  generator: createBlockArrow,
6941
7129
  defaultOptions: DEFAULT_BLOCK_ARROW_OPTIONS,
6942
7130
  rule: axis1DrawRule,
7131
+ optionHandles: createWidthOptionHandles({
7132
+ geometry(controlPoints, options) {
7133
+ const geometry = calculateBlockArrowGeometry(controlPoints, resolveBlockArrowOptions(options));
7134
+ if (!geometry) return null;
7135
+ return {
7136
+ spine: geometry.spine,
7137
+ leftEdge: geometry.leftSide,
7138
+ referenceHalfWidth: geometry.pathLength / 2
7139
+ };
7140
+ },
7141
+ minRatio: MIN_SHAFT_WIDTH_RATIO,
7142
+ maxRearRatio: MAX_SHAFT_WIDTH_RATIO,
7143
+ maxShaftRatio: MAX_SHAFT_WIDTH_RATIO
7144
+ }),
6943
7145
  previewSample: {
6944
7146
  controlPoints: [
6945
7147
  [1.4, 0],
@@ -9392,7 +9594,8 @@ const CLEAR = defineControlMeasure({
9392
9594
  //#endregion
9393
9595
  //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
9394
9596
  const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
9395
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
9597
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
9598
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
9396
9599
  smooth: false,
9397
9600
  smoothResolution: 5
9398
9601
  };
@@ -9464,12 +9667,14 @@ const SUPPORTING_ATTACK = defineControlMeasure({
9464
9667
  metadata: SUPPORTING_ATTACK_METADATA,
9465
9668
  generator: createSupportingAttack,
9466
9669
  defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
9467
- rule: axis1DrawRule
9670
+ rule: axis1DrawRule,
9671
+ optionHandles: variableWidthAttackOptionHandles
9468
9672
  });
9469
9673
  //#endregion
9470
9674
  //#region src/generators/cm34-mission-tasks/counterattack.ts
9471
9675
  const DEFAULT_COUNTERATTACK_OPTIONS = {
9472
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
9676
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
9677
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
9473
9678
  smooth: false,
9474
9679
  smoothResolution: 5,
9475
9680
  labelPosition: 1
@@ -9554,6 +9759,7 @@ const COUNTERATTACK = defineControlMeasure({
9554
9759
  generator: createCounterattack,
9555
9760
  defaultOptions: DEFAULT_COUNTERATTACK_OPTIONS,
9556
9761
  rule: axis1DrawRule,
9762
+ optionHandles: variableWidthAttackOptionHandles,
9557
9763
  previewSample: {
9558
9764
  controlPoints: [
9559
9765
  [1, 0],
@@ -9714,6 +9920,7 @@ const COUNTERATTACK_BY_FIRE = defineControlMeasure({
9714
9920
  generator: createCounterattackByFire,
9715
9921
  defaultOptions: DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS,
9716
9922
  rule: counterattackByFireDrawRule,
9923
+ optionHandles: createVariableWidthAttackOptionHandles(counterattackByFireBodyCoordinates),
9717
9924
  previewSample: {
9718
9925
  controlPoints: [
9719
9926
  [1.35, 0],
@@ -11061,7 +11268,7 @@ const DROP_ZONE_METADATA = {
11061
11268
  },
11062
11269
  drawRule: "Area1",
11063
11270
  capturesLabelSize: true,
11064
- params: LABELED_AREA_PARAMS,
11271
+ params: PREFIXED_LABELED_AREA_PARAMS,
11065
11272
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
11066
11273
  };
11067
11274
  /**
@@ -11278,7 +11485,7 @@ const EXTRACTION_ZONE_METADATA = {
11278
11485
  },
11279
11486
  drawRule: "Area1",
11280
11487
  capturesLabelSize: true,
11281
- params: LABELED_AREA_PARAMS,
11488
+ params: PREFIXED_LABELED_AREA_PARAMS,
11282
11489
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
11283
11490
  };
11284
11491
  /**
@@ -13153,7 +13360,8 @@ const FORTIFIED_AREA = defineControlMeasure({
13153
13360
  //#endregion
13154
13361
  //#region src/generators/cm15-maneuver-areas/maneuver-arrow-task-shared.ts
13155
13362
  const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
13156
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
13363
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
13364
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
13157
13365
  smooth: false,
13158
13366
  smoothResolution: 5,
13159
13367
  crossbarLengthRatio: 1.1,
@@ -13162,10 +13370,14 @@ const DEFAULT_MANEUVER_ARROW_TASK_OPTIONS = {
13162
13370
  labelPadding: 0
13163
13371
  };
13164
13372
  function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
13165
- const resolved = {
13373
+ const merged = {
13166
13374
  ...DEFAULT_MANEUVER_ARROW_TASK_OPTIONS,
13167
13375
  ...options
13168
13376
  };
13377
+ const resolved = {
13378
+ ...merged,
13379
+ rearWidthRatio: resolveRearWidthRatio(options, merged.shaftWidthRatio)
13380
+ };
13169
13381
  const { geometry } = processAttackGeometry(coordinates, resolved);
13170
13382
  if (!geometry) return {
13171
13383
  type: "FeatureCollection",
@@ -13186,17 +13398,22 @@ function createManeuverArrowTask(coordinates, options, textAmplifiers, config) {
13186
13398
  };
13187
13399
  const tipVector = vecSub(geometry.ptTip, geometry.ptBase);
13188
13400
  const tipDirection = vecNorm(tipVector);
13189
- const crossbarLength = vecMag(vecSub(geometry.headRing[0], geometry.headRing[2])) * Math.max(1, resolved.crossbarLengthRatio);
13401
+ const headWidth = vecMag(vecSub(geometry.headRing[0], geometry.headRing[2]));
13190
13402
  let crossbarCenter;
13191
13403
  let crossbarAlong;
13404
+ let crossbarBaseWidth = headWidth;
13192
13405
  if (config.crossbarAt === "tip") {
13193
13406
  crossbarCenter = geometry.ptTip;
13194
13407
  crossbarAlong = tipDirection;
13195
13408
  } else {
13196
- const crossbarFrame = pointAlongPolyline(segments, totalLength, Number.isFinite(config.crossbarAt.shaftPosition) ? clamp01(config.crossbarAt.shaftPosition) : 0);
13409
+ const shaftPosition = Number.isFinite(config.crossbarAt.shaftPosition) ? clamp01(config.crossbarAt.shaftPosition) : 0;
13410
+ const crossbarFrame = pointAlongPolyline(segments, totalLength, shaftPosition);
13197
13411
  crossbarCenter = crossbarFrame.point;
13198
13412
  crossbarAlong = crossbarFrame.along;
13413
+ const rearWidth = vecMag(vecSub(geometry.shaftLeft[0], geometry.shaftRight[0]));
13414
+ crossbarBaseWidth = rearWidth + (vecMag(vecSub(geometry.shaftLeft.at(-1), geometry.shaftRight.at(-1))) - rearWidth) * shaftPosition;
13199
13415
  }
13416
+ const crossbarLength = crossbarBaseWidth * Math.max(1, resolved.crossbarLengthRatio);
13200
13417
  const crossbarPerp = [-crossbarAlong[1], crossbarAlong[0]];
13201
13418
  const crossbar = [vecAdd(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2)), vecSub(crossbarCenter, vecScale(crossbarPerp, crossbarLength / 2))];
13202
13419
  const features = [{
@@ -13336,6 +13553,7 @@ const FRONTAL_ATTACK = defineControlMeasure({
13336
13553
  generator: createFrontalAttack,
13337
13554
  defaultOptions: DEFAULT_FRONTAL_ATTACK_OPTIONS,
13338
13555
  rule: axis1DrawRule,
13556
+ optionHandles: variableWidthAttackOptionHandles,
13339
13557
  previewSample: {
13340
13558
  controlPoints: [
13341
13559
  [1, 0],
@@ -14040,7 +14258,7 @@ const JOINT_TACTICAL_ACTION_AREA_METADATA = {
14040
14258
  },
14041
14259
  drawRule: "Area1",
14042
14260
  capturesLabelSize: true,
14043
- params: LABELED_AREA_PARAMS,
14261
+ params: PREFIXED_LABELED_AREA_PARAMS,
14044
14262
  textAmplifiers: AREA_TEXT_AMPLIFIERS
14045
14263
  };
14046
14264
  /**
@@ -14100,7 +14318,7 @@ const LANDING_ZONE_METADATA = {
14100
14318
  },
14101
14319
  drawRule: "Area1",
14102
14320
  capturesLabelSize: true,
14103
- params: LABELED_AREA_PARAMS,
14321
+ params: PREFIXED_LABELED_AREA_PARAMS,
14104
14322
  textAmplifiers: AREA_TEXT_AMPLIFIERS
14105
14323
  };
14106
14324
  /**
@@ -14373,7 +14591,8 @@ const NO_FIRE_AREA_IRREGULAR = defineControlMeasure({
14373
14591
  //#endregion
14374
14592
  //#region src/generators/cm15-maneuver-areas/mainAttack.ts
14375
14593
  const DEFAULT_MAIN_ATTACK_OPTIONS = {
14376
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
14594
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
14595
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
14377
14596
  smooth: false,
14378
14597
  smoothResolution: 5
14379
14598
  };
@@ -14401,7 +14620,7 @@ const MAIN_ATTACK_METADATA = {
14401
14620
  *
14402
14621
  * The symbol consists of a single MultiLineString feature containing:
14403
14622
  * 1. **Arrowhead**: A line forming a "chevron" or "roof" shape.
14404
- * 2. **Shaft**: Two parallel lines behind the arrow.
14623
+ * 2. **Shaft**: Two boundary lines behind the arrow, optionally flared toward the rear.
14405
14624
  *
14406
14625
  * The shaft is calculated to terminate exactly where it touches the inner walls
14407
14626
  * of the arrowhead, creating a seamless connection.
@@ -14440,7 +14659,8 @@ const MAIN_ATTACK = defineControlMeasure({
14440
14659
  metadata: MAIN_ATTACK_METADATA,
14441
14660
  generator: createMainAttack,
14442
14661
  defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
14443
- rule: axis1DrawRule
14662
+ rule: axis1DrawRule,
14663
+ optionHandles: variableWidthAttackOptionHandles
14444
14664
  });
14445
14665
  //#endregion
14446
14666
  //#region src/generators/cm27-protection-areas/mine-types.ts
@@ -15398,7 +15618,7 @@ const PICKUP_ZONE_METADATA = {
15398
15618
  },
15399
15619
  drawRule: "Area1",
15400
15620
  capturesLabelSize: true,
15401
- params: LABELED_AREA_PARAMS,
15621
+ params: PREFIXED_LABELED_AREA_PARAMS,
15402
15622
  textAmplifiers: AREA_TEXT_AMPLIFIERS
15403
15623
  };
15404
15624
  /**
@@ -15453,7 +15673,7 @@ const ASSAULT_POSITION_METADATA = {
15453
15673
  },
15454
15674
  drawRule: "Area1",
15455
15675
  capturesLabelSize: true,
15456
- params: LABELED_AREA_PARAMS,
15676
+ params: PREFIXED_LABELED_AREA_PARAMS,
15457
15677
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
15458
15678
  };
15459
15679
  /**
@@ -15508,7 +15728,7 @@ const ATTACK_POSITION_METADATA = {
15508
15728
  },
15509
15729
  drawRule: "Area1",
15510
15730
  capturesLabelSize: true,
15511
- params: LABELED_AREA_PARAMS,
15731
+ params: PREFIXED_LABELED_AREA_PARAMS,
15512
15732
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
15513
15733
  };
15514
15734
  /**
@@ -15563,7 +15783,7 @@ const OBJECTIVE_AREA_METADATA = {
15563
15783
  },
15564
15784
  drawRule: "Area1",
15565
15785
  capturesLabelSize: true,
15566
- params: LABELED_AREA_PARAMS,
15786
+ params: PREFIXED_LABELED_AREA_PARAMS,
15567
15787
  textAmplifiers: AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE
15568
15788
  };
15569
15789
  /**
@@ -16126,7 +16346,7 @@ const SUBMARINE_ACTION_AREA_METADATA = {
16126
16346
  },
16127
16347
  drawRule: "Area1",
16128
16348
  capturesLabelSize: true,
16129
- params: LABELED_AREA_PARAMS,
16349
+ params: PREFIXED_LABELED_AREA_PARAMS,
16130
16350
  textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO
16131
16351
  };
16132
16352
  /**
@@ -16186,7 +16406,7 @@ const SUBMARINE_GENERATED_ACTION_AREA_METADATA = {
16186
16406
  },
16187
16407
  drawRule: "Area1",
16188
16408
  capturesLabelSize: true,
16189
- params: LABELED_AREA_PARAMS,
16409
+ params: PREFIXED_LABELED_AREA_PARAMS,
16190
16410
  textAmplifiers: AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO
16191
16411
  };
16192
16412
  /**
@@ -16659,6 +16879,7 @@ const DEFINITIONS = {
16659
16879
  generator: createTurningMovement,
16660
16880
  defaultOptions: DEFAULT_TURNING_MOVEMENT_OPTIONS,
16661
16881
  rule: axis1DrawRule,
16882
+ optionHandles: variableWidthAttackOptionHandles,
16662
16883
  previewSample: {
16663
16884
  controlPoints: [
16664
16885
  [1, 0],