@orbat-mapper/control-measures 0.2.0-alpha.0 → 0.2.0-alpha.2

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 (43) hide show
  1. package/README.md +90 -34
  2. package/dist/index-D7uBPw7l.d.mts +1653 -0
  3. package/dist/index.d.mts +1 -1353
  4. package/dist/index.mjs +1 -4964
  5. package/dist/preview/index.d.mts +45 -0
  6. package/dist/preview/index.mjs +211 -0
  7. package/dist/renderControlMeasure-C7pY-TcL.mjs +6465 -0
  8. package/media/airborne-attack.svg +1 -0
  9. package/media/ambush.svg +1 -0
  10. package/media/antitank-ditch-completed.svg +1 -0
  11. package/media/antitank-ditch-under-construction.svg +1 -0
  12. package/media/antitank-wall.svg +1 -0
  13. package/media/attack-by-fire.svg +1 -0
  14. package/media/attack-helicopter.svg +1 -0
  15. package/media/battle-position.svg +1 -0
  16. package/media/block-arrow.svg +1 -0
  17. package/media/block-mission-task.svg +1 -0
  18. package/media/block.svg +1 -0
  19. package/media/boundary.svg +1 -0
  20. package/media/breach.svg +1 -0
  21. package/media/bypass.svg +1 -0
  22. package/media/canalize.svg +1 -0
  23. package/media/classic-arrow.svg +1 -0
  24. package/media/clear.svg +1 -0
  25. package/media/delay.svg +1 -0
  26. package/media/disrupt.svg +1 -0
  27. package/media/final-protective-fire-left.svg +1 -0
  28. package/media/final-protective-fire-right.svg +1 -0
  29. package/media/fix.svg +1 -0
  30. package/media/flot.svg +1 -0
  31. package/media/fortified-area.svg +1 -0
  32. package/media/fortified-line.svg +1 -0
  33. package/media/isolate.svg +1 -0
  34. package/media/main-attack.svg +1 -0
  35. package/media/obstacle-bypass-difficult.svg +1 -0
  36. package/media/obstacle-bypass-easy.svg +1 -0
  37. package/media/obstacle-bypass-impossible.svg +1 -0
  38. package/media/principal-direction-of-fire.svg +1 -0
  39. package/media/search-area.svg +1 -0
  40. package/media/support-by-fire.svg +1 -0
  41. package/media/supporting-attack.svg +1 -0
  42. package/media/turn.svg +1 -0
  43. package/package.json +8 -2
package/dist/index.mjs CHANGED
@@ -1,4967 +1,4 @@
1
- //#region src/internal/graphics-utils.ts
2
- /**
3
- * Small value used to avoid division by zero and floating-point precision issues.
4
- */
5
- const EPSILON = 1e-6;
6
- /**
7
- * Clamps a value to the range [0, 1].
8
- */
9
- const clamp01 = (v) => Math.min(1, Math.max(0, v));
10
- /**
11
- * Clamps a value to the range [min, max].
12
- */
13
- const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
14
- /**
15
- * Type guard for finite numbers.
16
- */
17
- const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
18
- /**
19
- * Validates that a position array contains at least two finite coordinates.
20
- */
21
- const isValidPosition = (p) => Array.isArray(p) && p.length >= 2 && isFiniteNumber(p[0]) && isFiniteNumber(p[1]);
22
- /**
23
- * Rounds a number to a specified number of decimal places.
24
- */
25
- const roundToFixed = (v, precision = 6) => {
26
- const m = Math.pow(10, precision);
27
- return Math.round(v * m) / m;
28
- };
29
- /**
30
- * Earth's circumference at the equator in meters.
31
- */
32
- const EARTH_CIRCUMFERENCE = 40075016.686;
33
- /**
34
- * Standard tile size in pixels for Web Mercator projection.
35
- */
36
- const TILE_SIZE = 256;
37
- /**
38
- * Calculates the meters-per-pixel ratio at a given latitude and zoom level.
39
- *
40
- * This is essential for maintaining consistent visual sizes on the map
41
- * regardless of zoom level. The formula accounts for:
42
- * - The Web Mercator projection's tile-based structure
43
- * - The latitude-dependent scale distortion in Mercator projection
44
- *
45
- * @param latitude - The latitude in degrees (affects scale due to projection)
46
- * @param zoomLevel - The current map zoom level
47
- * @returns The number of meters represented by one pixel at the given location and zoom
48
- *
49
- * @example
50
- * ```typescript
51
- * // At zoom 10, latitude 45°
52
- * const mpp = getMetersPerPixel(45, 10);
53
- * // To get a 20-pixel radius in meters:
54
- * const radiusMeters = 20 * mpp;
55
- * ```
56
- */
57
- const getMetersPerPixel = (latitude, zoomLevel) => {
58
- const metersPerPixelAtEquator = EARTH_CIRCUMFERENCE / (TILE_SIZE * Math.pow(2, zoomLevel));
59
- const latitudeRadians = latitude * Math.PI / 180;
60
- return metersPerPixelAtEquator * Math.cos(latitudeRadians);
61
- };
62
- //#endregion
63
- //#region src/projection.ts
64
- /**
65
- * Project WGS84 (Lon/Lat) to Web Mercator (Meters).
66
- */
67
- const project = (lon, lat) => {
68
- const x = lon * 20037508.34 / 180;
69
- let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
70
- y = y * 20037508.34 / 180;
71
- return [x, y];
72
- };
73
- /**
74
- * Unproject Web Mercator (Meters) back to WGS84 (Lon/Lat).
75
- */
76
- const unproject = (x, y) => {
77
- const lon = x * 180 / 20037508.34;
78
- let lat = y * 180 / 20037508.34;
79
- lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2);
80
- return [roundToFixed(lon), roundToFixed(lat)];
81
- };
82
- //#endregion
83
- //#region src/internal/vector-utils.ts
84
- /**
85
- * Offsets a polyline using Miter Joins or Round Joins.
86
- */
87
- function offsetPolyline(points, offset, rounded = false, segments = 5) {
88
- if (points.length < 2) return points;
89
- const result = [];
90
- const N = points.length;
91
- for (let i = 0; i < N; i++) {
92
- const p = points[i];
93
- if (!p) continue;
94
- if (i === 0) {
95
- const next = points[i + 1];
96
- if (next) {
97
- const dir = vecNorm(vecSub(next, p));
98
- const normal = [-dir[1], dir[0]];
99
- result.push(vecAdd(p, vecScale(normal, offset)));
100
- }
101
- continue;
102
- }
103
- if (i === N - 1) {
104
- const prev = points[i - 1];
105
- if (prev) {
106
- const dir = vecNorm(vecSub(p, prev));
107
- const normal = [-dir[1], dir[0]];
108
- result.push(vecAdd(p, vecScale(normal, offset)));
109
- }
110
- continue;
111
- }
112
- const prev = points[i - 1];
113
- const next = points[i + 1];
114
- if (!prev || !next) continue;
115
- const v1 = vecNorm(vecSub(p, prev));
116
- const v2 = vecNorm(vecSub(next, p));
117
- const n1 = [-v1[1], v1[0]];
118
- const n2 = [-v2[1], v2[0]];
119
- const cross = v1[0] * v2[1] - v1[1] * v2[0];
120
- if (rounded && (offset > 0 && cross < -1e-6 || offset < 0 && cross > 1e-6)) {
121
- const angle1 = Math.atan2(n1[1], n1[0]);
122
- let diff = Math.atan2(n2[1], n2[0]) - angle1;
123
- if (offset > 0) {
124
- while (diff > EPSILON) diff -= 2 * Math.PI;
125
- while (diff < -2 * Math.PI) diff += 2 * Math.PI;
126
- } else {
127
- while (diff < -1e-6) diff += 2 * Math.PI;
128
- while (diff > 2 * Math.PI) diff -= 2 * Math.PI;
129
- }
130
- const segmentCount = Math.max(1, segments);
131
- for (let j = 0; j <= segmentCount; j++) {
132
- const t = j / segmentCount;
133
- const theta = angle1 + diff * t;
134
- const nx = Math.cos(theta);
135
- const ny = Math.sin(theta);
136
- result.push([p[0] + nx * offset, p[1] + ny * offset]);
137
- }
138
- } else {
139
- const tangent = vecNorm(vecAdd(v1, v2));
140
- const miter = [-tangent[1], tangent[0]];
141
- const dot = vecDot(miter, n1);
142
- const miterLimit = 3;
143
- const scaleFactor = Math.abs(dot) < 1e-6 ? 1 : 1 / dot;
144
- const normal = vecScale(miter, Math.min(scaleFactor, miterLimit));
145
- result.push(vecAdd(p, vecScale(normal, offset)));
146
- }
147
- }
148
- return result;
149
- }
150
- function vecAdd(a, b) {
151
- return [a[0] + b[0], a[1] + b[1]];
152
- }
153
- function vecSub(a, b) {
154
- return [a[0] - b[0], a[1] - b[1]];
155
- }
156
- function vecScale(v, s) {
157
- return [v[0] * s, v[1] * s];
158
- }
159
- function vecDot(a, b) {
160
- return a[0] * b[0] + a[1] * b[1];
161
- }
162
- function vecMag(v) {
163
- return Math.sqrt(v[0] * v[0] + v[1] * v[1]);
164
- }
165
- function vecNorm(v) {
166
- const m = vecMag(v);
167
- return m === 0 ? [0, 0] : [v[0] / m, v[1] / m];
168
- }
169
- function pointToPointDistance(p1, p2) {
170
- const dx = p2[0] - p1[0];
171
- const dy = p2[1] - p1[1];
172
- return Math.sqrt(dx * dx + dy * dy);
173
- }
174
- function pointToInfiniteLineDistance(p, a, b) {
175
- const ab = vecSub(b, a);
176
- const ap = vecSub(p, a);
177
- const abLenSq = ab[0] * ab[0] + ab[1] * ab[1];
178
- if (abLenSq < 1e-6) return pointToPointDistance(p, a);
179
- const t = vecDot(ap, ab) / abLenSq;
180
- return pointToPointDistance(p, [a[0] + t * ab[0], a[1] + t * ab[1]]);
181
- }
182
- /**
183
- * Calculates intersection of two line segments p1-p2 and p3-p4.
184
- * Returns null if parallel. Assuming they cross as per design.
185
- */
186
- function lineIntersection(p1, p2, p3, p4) {
187
- const x1 = p1[0], y1 = p1[1];
188
- const x2 = p2[0], y2 = p2[1];
189
- const x3 = p3[0], y3 = p3[1];
190
- const x4 = p4[0], y4 = p4[1];
191
- const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
192
- if (Math.abs(denom) < 1e-6) return null;
193
- const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
194
- return [x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)];
195
- }
196
- //#endregion
197
- //#region src/attack-utils.ts
198
- const DEFAULT_SHAFT_WIDTH_RATIO = .6;
199
- const SHAFT_MIN_RATIO = .1;
200
- const SHAFT_MAX_RATIO = .9;
201
- /**
202
- * Processes input coordinates and options to generate the core symbol geometry.
203
- * This handles validation, default values, projection, and geometry calculation.
204
- */
205
- function processAttackGeometry(coordinates, options = {}) {
206
- const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
207
- const roundedBends = options.roundedBends ?? false;
208
- const bendSegments = options.bendSegments ?? 5;
209
- const points = coordinates.map((c) => project(c[0], c[1]));
210
- const numPoints = points.length;
211
- const ptTip = points[0];
212
- const ptWidth = points[numPoints - 1];
213
- const spinePoints = [];
214
- for (let i = numPoints - 2; i >= 1; i--) {
215
- const p = points[i];
216
- if (p) spinePoints.push(p);
217
- }
218
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, roundedBends, bendSegments) };
219
- }
220
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, roundedBends, bendSegments) {
221
- const ptNeck = spine[spine.length - 1];
222
- if (!ptNeck) return null;
223
- const tipDir = vecNorm(vecSub(ptTip, ptNeck));
224
- if (vecMag(tipDir) < 1e-6) return null;
225
- const headHalfWidth = pointToInfiniteLineDistance(ptWidth, ptNeck, ptTip);
226
- const vecTipToWidth = vecSub(ptTip, ptWidth);
227
- const maxHeadLength = Math.max(0, vecMag(vecSub(ptTip, ptNeck)) - .1);
228
- const headLength = clamp(vecDot(vecTipToWidth, tipDir), EPSILON, maxHeadLength);
229
- const ptBase = vecSub(ptTip, vecScale(tipDir, headLength));
230
- const shaftHalfWidth = headHalfWidth * shaftRatio;
231
- const perpDir = [-tipDir[1], tipDir[0]];
232
- const outerLeft = vecAdd(ptBase, vecScale(perpDir, headHalfWidth));
233
- const outerRight = vecAdd(ptBase, vecScale(perpDir, -headHalfWidth));
234
- const innerLeft = vecAdd(ptBase, vecScale(perpDir, shaftHalfWidth));
235
- const innerRight = vecAdd(ptBase, vecScale(perpDir, -shaftHalfWidth));
236
- const notchDepth = headLength * shaftRatio;
237
- const innerTip = vecAdd(ptBase, vecScale(tipDir, Math.min(notchDepth, headLength - EPSILON)));
238
- const shaftEndCenter = ptBase;
239
- const headRing = [
240
- outerLeft,
241
- ptTip,
242
- outerRight,
243
- innerRight,
244
- innerTip,
245
- innerLeft,
246
- outerLeft
247
- ];
248
- const fullSpine = [...spine, shaftEndCenter];
249
- return {
250
- shaftLeft: offsetPolyline(fullSpine, shaftHalfWidth, roundedBends, bendSegments),
251
- shaftRight: offsetPolyline(fullSpine, -shaftHalfWidth, roundedBends, bendSegments),
252
- headRing,
253
- ptTip,
254
- ptNeck,
255
- ptBase
256
- };
257
- }
258
- /**
259
- * Calculates the relative metrics (longitudinal and lateral) of the width point
260
- * relative to the Tip-Neck axis.
261
- *
262
- * @param pts - Array of positions [Tip, Neck, ..., WidthPoint]
263
- * @returns { longitudinal: number, lateral: number } | null
264
- */
265
- function calculateMetrics(pts) {
266
- const tip = pts[0];
267
- const neck = pts[1];
268
- const widthPt = pts[pts.length - 1];
269
- if (!tip || !neck || !widthPt) return null;
270
- const tipM = project(tip[0], tip[1]);
271
- const neckM = project(neck[0], neck[1]);
272
- const widthM = project(widthPt[0], widthPt[1]);
273
- const tx = tipM[0];
274
- const ty = tipM[1];
275
- const nx = neckM[0];
276
- const ny = neckM[1];
277
- const wx = widthM[0];
278
- const wy = widthM[1];
279
- const dx = tx - nx;
280
- const dy = ty - ny;
281
- const len = Math.sqrt(dx * dx + dy * dy);
282
- if (len < 1e-6) return null;
283
- const ax = dx / len;
284
- const ay = dy / len;
285
- const px = -ay;
286
- const py = ax;
287
- const vx = wx - tx;
288
- const vy = wy - ty;
289
- return {
290
- longitudinal: vx * ax + vy * ay,
291
- lateral: vx * px + vy * py
292
- };
293
- }
294
- /**
295
- * Computes an initial Width Point based on the Tip and Neck positions.
296
- * It places the point perpendicular to the neck at a default distance relative to the length.
297
- *
298
- * @param tip - Tip position (Lon/Lat)
299
- * @param neck - Neck position (Lon/Lat)
300
- * @returns Width Point position (Lon/Lat)
301
- */
302
- function computeInitialWidthPoint(tip, neck) {
303
- const tipM = project(tip[0], tip[1]);
304
- const neckM = project(neck[0], neck[1]);
305
- const tx = tipM[0];
306
- const ty = tipM[1];
307
- const nx = neckM[0];
308
- const ny = neckM[1];
309
- const dx = tx - nx;
310
- const dy = ty - ny;
311
- const len = Math.sqrt(dx * dx + dy * dy);
312
- if (len < 1e-6) return unproject(nx + 100, ny);
313
- const ax = dx / len;
314
- const ay = dy / len;
315
- const widthRatio = .35;
316
- const recessionRatio = .25;
317
- const px = -ay;
318
- const py = ax;
319
- return unproject(tx - dx * recessionRatio + len * widthRatio * px, ty - dy * recessionRatio + len * widthRatio * py);
320
- }
321
- //#endregion
322
- //#region src/define.ts
323
- /**
324
- * Identity helper that infers a definition's literal `Id` and precise generator
325
- * type `G`, so the registry's `typeof DEFINITIONS` carries each measure's exact
326
- * options type. Internal authoring API — not part of the published surface.
327
- */
328
- function defineControlMeasure(definition) {
329
- return definition;
330
- }
331
- //#endregion
332
- //#region src/draw-rules/baseline-frame.ts
333
- function createBaselineFrame(p1, p2, options = {}) {
334
- return createProjectedBaselineFrame(project(p1[0], p1[1]), project(p2[0], p2[1]), options);
335
- }
336
- function createProjectedBaselineFrame(p1, p2, options = {}) {
337
- const baseline = vecSub(p2, p1);
338
- const length = vecMag(baseline);
339
- if (length < 1e-6) return null;
340
- const direction = vecNorm(baseline);
341
- const normalSign = options.normal === "left" ? -1 : 1;
342
- const normal = vecScale([direction[1], -direction[0]], normalSign);
343
- const origin = getOrigin(p1, p2, options.origin ?? "midpoint");
344
- return {
345
- p1,
346
- p2,
347
- origin,
348
- direction,
349
- normal,
350
- length,
351
- signedNormalDistance(point) {
352
- return vecDot(vecSub(project(point[0], point[1]), origin), normal);
353
- },
354
- pointAtNormalDistance(distance) {
355
- if (!Number.isFinite(distance)) return null;
356
- const point = vecAdd(origin, vecScale(normal, distance));
357
- return unproject(point[0], point[1]);
358
- }
359
- };
360
- }
361
- function getOrigin(p1, p2, origin) {
362
- if (origin === "p1") return p1;
363
- if (origin === "p2") return p2;
364
- return vecScale(vecAdd(p1, p2), .5);
365
- }
366
- //#endregion
367
- //#region src/draw-rules/util.ts
368
- function clonePosition(position) {
369
- return [...position];
370
- }
371
- function clonePositions(positions) {
372
- return positions.map(clonePosition);
373
- }
374
- function samePosition$1(a, b) {
375
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
376
- }
377
- //#endregion
378
- //#region src/draw-rules/midpoint-perpendicular.ts
379
- function createMidpointPerpendicularDrawRule(options) {
380
- const defaultDistanceRatio = options.defaultDistanceRatio ?? 1.5;
381
- const constrainedIndex = options.constrainedIndex ?? 2;
382
- const baselineIndexA = constrainedIndex === 0 ? 1 : 0;
383
- const baselineIndexB = constrainedIndex === 0 ? 2 : 1;
384
- const frameOrigin = options.origin ?? "midpoint";
385
- const frameNormal = options.normal ?? "right";
386
- function frameFor(p1, p2) {
387
- return createBaselineFrame(p1, p2, {
388
- origin: frameOrigin,
389
- normal: frameNormal
390
- });
391
- }
392
- function packSlots(baselineA, baselineB, constrained) {
393
- const out = new Array(3);
394
- out[baselineIndexA] = baselineA;
395
- out[baselineIndexB] = baselineB;
396
- out[constrainedIndex] = constrained;
397
- return out;
398
- }
399
- function snap(baselineA, baselineB, point) {
400
- const frame = frameFor(baselineA, baselineB);
401
- if (!frame) return null;
402
- return frame.pointAtNormalDistance(frame.signedNormalDistance(point));
403
- }
404
- function derive(points) {
405
- if (points.length < 2) return clonePositions(points);
406
- if (points.length === 2) {
407
- const baselineA = clonePosition(points[0]);
408
- const baselineB = clonePosition(points[1]);
409
- const frame = frameFor(baselineA, baselineB);
410
- const derived = frame ? frame.pointAtNormalDistance(frame.length * defaultDistanceRatio) : null;
411
- return derived ? packSlots(baselineA, baselineB, derived) : [baselineA, baselineB];
412
- }
413
- const baselineA = clonePosition(points[baselineIndexA]);
414
- const baselineB = clonePosition(points[baselineIndexB]);
415
- const rawConstrained = points[constrainedIndex];
416
- return packSlots(baselineA, baselineB, snap(baselineA, baselineB, rawConstrained) ?? clonePosition(rawConstrained));
417
- }
418
- return {
419
- id: options.id,
420
- minimumUserPoints: options.minimumUserPoints ?? 2,
421
- minimumPreviewPoints: options.minimumPreviewPoints,
422
- canonicalPointCount: 3,
423
- derive,
424
- transform(event) {
425
- const { previous, next, activePointIndex } = event;
426
- if (previous.length < 3 || next.length < 3) return derive(next);
427
- const oldBaselineA = previous[baselineIndexA];
428
- const oldBaselineB = previous[baselineIndexB];
429
- const oldConstrained = previous[constrainedIndex];
430
- const newBaselineA = next[baselineIndexA];
431
- const newBaselineB = next[baselineIndexB];
432
- const newConstrained = next[constrainedIndex];
433
- if (!oldBaselineA || !oldBaselineB || !oldConstrained || !newBaselineA || !newBaselineB || !newConstrained) return derive(next);
434
- const baselineAMoved = !samePosition(newBaselineA, oldBaselineA);
435
- const baselineBMoved = !samePosition(newBaselineB, oldBaselineB);
436
- const constrainedMoved = !samePosition(newConstrained, oldConstrained);
437
- const draggingBaseline = activePointIndex === baselineIndexA || activePointIndex === baselineIndexB;
438
- const draggingConstrained = activePointIndex === constrainedIndex;
439
- if (draggingBaseline || (baselineAMoved || baselineBMoved) && !constrainedMoved) {
440
- const oldFrame = frameFor(oldBaselineA, oldBaselineB);
441
- const newFrame = frameFor(newBaselineA, newBaselineB);
442
- if (oldFrame && newFrame) {
443
- const distance = oldFrame.signedNormalDistance(oldConstrained);
444
- const adjusted = newFrame.pointAtNormalDistance(distance);
445
- if (adjusted) return packSlots(clonePosition(newBaselineA), clonePosition(newBaselineB), adjusted);
446
- }
447
- } else if (draggingConstrained || !baselineAMoved && !baselineBMoved && constrainedMoved) {
448
- const adjusted = snap(newBaselineA, newBaselineB, newConstrained);
449
- if (adjusted) return packSlots(clonePosition(newBaselineA), clonePosition(newBaselineB), adjusted);
450
- }
451
- return derive(next);
452
- }
453
- };
454
- }
455
- function computeDefaultMidpointPerpendicularPoint(p1, p2, distanceRatio = 1.5) {
456
- const frame = frameFor(p1, p2);
457
- if (!frame) return null;
458
- return frame.pointAtNormalDistance(frame.length * distanceRatio);
459
- }
460
- function getMidpointPerpendicularSignedDistance(p1, p2, controlPoint) {
461
- const frame = frameFor(p1, p2);
462
- if (!frame) return null;
463
- return frame.signedNormalDistance(controlPoint);
464
- }
465
- function pointOnMidpointPerpendicularAxis(p1, p2, distance) {
466
- if (distance === null) return null;
467
- const frame = frameFor(p1, p2);
468
- if (!frame) return null;
469
- return frame.pointAtNormalDistance(distance);
470
- }
471
- function snapToMidpointPerpendicular(p1, p2, controlPoint) {
472
- const frame = frameFor(p1, p2);
473
- if (!frame) return null;
474
- return frame.pointAtNormalDistance(frame.signedNormalDistance(controlPoint));
475
- }
476
- function frameFor(p1, p2) {
477
- return createBaselineFrame(p1, p2, {
478
- origin: "midpoint",
479
- normal: "right"
480
- });
481
- }
482
- function samePosition(a, b) {
483
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
484
- }
485
- //#endregion
486
- //#region src/draw-rules/area11.ts
487
- const blockDrawRule = createMidpointPerpendicularDrawRule({ id: "area11:block" });
488
- //#endregion
489
- //#region src/draw-rules/area12.ts
490
- /**
491
- * Area12 anchor draw rule for Disrupt.
492
- *
493
- * P1/P2 are the free spine endpoints. P3 is the longest-arrow tip, constrained
494
- * to the perpendicular axis through P1 (not the baseline midpoint).
495
- */
496
- const disruptDrawRule = createMidpointPerpendicularDrawRule({
497
- id: "area12:disrupt",
498
- origin: "p1",
499
- normal: "left"
500
- });
501
- //#endregion
502
- //#region src/draw-rules/area15.ts
503
- /**
504
- * Area15 — center + radius. PT. 1 is the center point; PT. 2 is the start point
505
- * that fixes both the radius and the bearing of the symbol's opening. Both
506
- * points are user-clicked, so the canonical array is just the (clamped) input.
507
- */
508
- function derive$6(points) {
509
- return clonePositions(points.slice(0, 2));
510
- }
511
- const isolateDrawRule = {
512
- id: "area15:isolate",
513
- minimumUserPoints: 2,
514
- canonicalPointCount: 2,
515
- derive: derive$6,
516
- transform(event) {
517
- const { previous, next, activePointIndex } = event;
518
- if (activePointIndex === 0 && previous.length >= 2 && next.length >= 2) {
519
- const dx = next[0][0] - previous[0][0];
520
- const dy = next[0][1] - previous[0][1];
521
- const origin = previous[1];
522
- return [clonePosition(next[0]), [origin[0] + dx, origin[1] + dy]];
523
- }
524
- return derive$6(next);
525
- }
526
- };
527
- //#endregion
528
- //#region src/draw-rules/point12.ts
529
- /**
530
- * Point12 anchor draw rule for the obstacle-bypass family.
531
- *
532
- * Shared by `obstacle-bypass-easy`, `obstacle-bypass-difficult`, and
533
- * `obstacle-bypass-impossible`: P1/P2 are the arrowhead tips and define the
534
- * opening; P3 is constrained to the perpendicular axis through
535
- * `midpoint(P1, P2)` and defines the rear of the symbol. Rear-line decoration
536
- * (plain, zigzag, barrier marks) is a generator concern, not a draw-rule one.
537
- */
538
- const point12DrawRule = createMidpointPerpendicularDrawRule({ id: "point12:obstacle-bypass" });
539
- //#endregion
540
- //#region src/draw-rules/area7.ts
541
- /**
542
- * Area7 anchor draw rule for Attack By Fire.
543
- *
544
- * P2/P3 are the free back-line endpoints; P1 (slot index 0) is the arrowhead
545
- * tip, constrained to the perpendicular axis through `midpoint(P2, P3)`.
546
- */
547
- const attackByFireDrawRule = createMidpointPerpendicularDrawRule({
548
- id: "area7:attack-by-fire",
549
- constrainedIndex: 0,
550
- defaultDistanceRatio: -1.5
551
- });
552
- //#endregion
553
- //#region src/draw-rules/line29.ts
554
- /**
555
- * Line29 anchor draw rule for Ambush.
556
- *
557
- * P2/P3 are the free back-line endpoints; P1 (slot index 0) is the arrowhead
558
- * tip, constrained to the perpendicular axis through `midpoint(P2, P3)`.
559
- */
560
- const ambushDrawRule = createMidpointPerpendicularDrawRule({
561
- id: "line29:ambush",
562
- constrainedIndex: 0,
563
- defaultDistanceRatio: -.5
564
- });
565
- //#endregion
566
- //#region src/draw-rules/line1.ts
567
- function derive$5(points) {
568
- return clonePositions(points);
569
- }
570
- const line1DrawRule = {
571
- id: "line1",
572
- minimumUserPoints: 2,
573
- derive: derive$5,
574
- transform(event) {
575
- return derive$5(event.next);
576
- }
577
- };
578
- //#endregion
579
- //#region src/draw-rules/line3.ts
580
- function derive$4(points) {
581
- return clonePositions(points);
582
- }
583
- /**
584
- * Line3 anchor draw rule (e.g. Final Protective Fire / Principal Direction of
585
- * Fire).
586
- *
587
- * Three fully free anchor points: P1 (slot index 0) is the vertex, P2/P3 are
588
- * the arrowhead tips. The arms' lengths and orientations vary independently, so
589
- * there is no constraint between slots — every handle moves on its own.
590
- */
591
- const line3DrawRule = {
592
- id: "line3",
593
- minimumUserPoints: 3,
594
- canonicalPointCount: 3,
595
- derive: derive$4,
596
- transform(event) {
597
- return derive$4(event.next);
598
- }
599
- };
600
- //#endregion
601
- //#region src/draw-rules/line9.ts
602
- function derive$3(points) {
603
- return clonePositions(points);
604
- }
605
- const line9DrawRule = {
606
- id: "line9",
607
- minimumUserPoints: 2,
608
- canonicalPointCount: 2,
609
- derive: derive$3,
610
- transform(event) {
611
- return derive$3(event.next);
612
- }
613
- };
614
- //#endregion
615
- //#region src/internal/quarter-arc.ts
616
- function quarterArcSide(start, end, hint, fallback = 1) {
617
- if (!hint) return fallback;
618
- const chord = vecSub(end, start);
619
- const offset = vecSub(hint, vecScale(vecAdd(start, end), .5));
620
- const cross = chord[0] * offset[1] - chord[1] * offset[0];
621
- return Math.abs(cross) < 1e-6 ? fallback : cross > 0 ? 1 : -1;
622
- }
623
- /**
624
- * Builds the minor 90-degree circular arc from `start` to `end`.
625
- *
626
- * `side` selects which side of the directed chord contains the arc itself:
627
- * `1` is left of start -> end, and `-1` is right.
628
- */
629
- function createQuarterArc(start, end, side) {
630
- const chord = vecSub(end, start);
631
- const chordLength = vecMag(chord);
632
- if (chordLength < 1e-6) return null;
633
- const chordDir = vecNorm(chord);
634
- const leftNormal = [-chordDir[1], chordDir[0]];
635
- const center = vecAdd(vecScale(vecAdd(start, end), .5), vecScale(leftNormal, -side * chordLength / 2));
636
- const radius = chordLength / Math.SQRT2;
637
- const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]);
638
- const sweepAngle = -side * Math.PI / 2;
639
- const midpointAngle = startAngle + sweepAngle / 2;
640
- return {
641
- center,
642
- radius,
643
- startAngle,
644
- sweepAngle,
645
- midpoint: vecAdd(center, [radius * Math.cos(midpointAngle), radius * Math.sin(midpointAngle)])
646
- };
647
- }
648
- //#endregion
649
- //#region src/draw-rules/line10.ts
650
- function deriveWithSide(points, preservedSide) {
651
- if (points.length < 2) return clonePositions(points);
652
- const p1 = clonePosition(points[0]);
653
- const p2 = clonePosition(points[1]);
654
- const tip = project(p1[0], p1[1]);
655
- const rear = project(p2[0], p2[1]);
656
- const hint = points[2] ? project(points[2][0], points[2][1]) : void 0;
657
- const arc = createQuarterArc(rear, tip, preservedSide ?? quarterArcSide(rear, tip, hint));
658
- return arc ? [
659
- p1,
660
- p2,
661
- unproject(arc.midpoint[0], arc.midpoint[1])
662
- ] : [p1, p2];
663
- }
664
- function sideFrom(points) {
665
- const tip = project(points[0][0], points[0][1]);
666
- return quarterArcSide(project(points[1][0], points[1][1]), tip, project(points[2][0], points[2][1]));
667
- }
668
- /**
669
- * Line10 — PT1 is the arrow tip, PT2 is the rear, and derived PT3 selects
670
- * which side of the PT2 -> PT1 chord contains the 90-degree arc.
671
- */
672
- const turnDrawRule = {
673
- id: "line10:turn",
674
- minimumUserPoints: 2,
675
- canonicalPointCount: 3,
676
- derive: deriveWithSide,
677
- transform(event) {
678
- const { previous, next, activePointIndex } = event;
679
- if (previous.length < 3 || next.length < 3) return deriveWithSide(next);
680
- const endpointMoved = !samePosition$1(previous[0], next[0]) || !samePosition$1(previous[1], next[1]);
681
- if (activePointIndex === 0 || activePointIndex === 1 || endpointMoved) return deriveWithSide(next, sideFrom(previous));
682
- return deriveWithSide(next);
683
- }
684
- };
685
- //#endregion
686
- //#region src/draw-rules/line23.ts
687
- /**
688
- * Line23 anchor draw rule for the Clear mission task.
689
- *
690
- * P1/P2 are the endpoints of the symbol's vertical line (its height); P3 is
691
- * constrained to the perpendicular axis through `midpoint(P1, P2)` and defines
692
- * the rear of the symbol (the shaft length). Geometrically identical to the
693
- * obstacle-bypass `Point12` rule, but kept as its own spec id to match the
694
- * doctrinal draw rule for Clear.
695
- */
696
- const line23DrawRule = createMidpointPerpendicularDrawRule({ id: "line23:clear" });
697
- //#endregion
698
- //#region src/draw-rules/line24.ts
699
- /**
700
- * Line24 anchor draw rule for the Delay mission task.
701
- *
702
- * PT. 1 and PT. 2 are the free straight-line endpoints. PT. 3 is constrained to
703
- * the perpendicular axis through PT. 2, where its signed distance from PT. 2
704
- * defines the semicircular arc diameter and side. Unlike the sibling Line23
705
- * (Clear) rule, the third point must be placed explicitly (three user clicks),
706
- * with a two-point live preview before commit.
707
- */
708
- const line24DrawRule = createMidpointPerpendicularDrawRule({
709
- id: "line24:delay",
710
- origin: "p2",
711
- defaultDistanceRatio: .6,
712
- minimumUserPoints: 3,
713
- minimumPreviewPoints: 2
714
- });
715
- //#endregion
716
- //#region src/draw-rules/area8.ts
717
- function derive$2(points) {
718
- if (points.length < 2) return clonePositions(points);
719
- if (points.length !== 2) return clonePositions(points.slice(0, 4));
720
- const p1 = clonePosition(points[0]);
721
- const p2 = clonePosition(points[1]);
722
- const frame = createBaselineFrame(p1, p2, {
723
- origin: "p1",
724
- normal: "left"
725
- });
726
- if (!frame) return [p1, p2];
727
- const offset = vecScale(frame.normal, frame.length);
728
- const p3Projected = vecAdd(frame.p1, offset);
729
- const p4Projected = vecAdd(frame.p2, offset);
730
- return [
731
- p1,
732
- p2,
733
- unproject(p3Projected[0], p3Projected[1]),
734
- unproject(p4Projected[0], p4Projected[1])
735
- ];
736
- }
737
- const supportByFireDrawRule = {
738
- id: "area8:support-by-fire",
739
- minimumUserPoints: 2,
740
- canonicalPointCount: 4,
741
- derive: derive$2,
742
- transform(event) {
743
- const { next } = event;
744
- if (next.length === 4) return clonePositions(next);
745
- return derive$2(next);
746
- }
747
- };
748
- //#endregion
749
- //#region src/draw-rules/axis1.ts
750
- function derive$1(points) {
751
- if (points.length < 2) return clonePositions(points);
752
- if (points.length === 2) {
753
- const tip = clonePosition(points[0]);
754
- const neck = clonePosition(points[1]);
755
- return [
756
- tip,
757
- neck,
758
- computeInitialWidthPoint(tip, neck)
759
- ];
760
- }
761
- return clonePositions(points);
762
- }
763
- function projectWidthIntoFrame(next, longitudinal, lateral) {
764
- const tip = next[0];
765
- const neck = next[1];
766
- if (!tip || !neck) return null;
767
- const tipM = project(tip[0], tip[1]);
768
- const neckM = project(neck[0], neck[1]);
769
- const dx = tipM[0] - neckM[0];
770
- const dy = tipM[1] - neckM[1];
771
- const len = Math.sqrt(dx * dx + dy * dy);
772
- if (len < 1e-6) return null;
773
- const ax = dx / len;
774
- const ay = dy / len;
775
- const px = -ay;
776
- const py = ax;
777
- return unproject(tipM[0] + longitudinal * ax + lateral * px, tipM[1] + longitudinal * ay + lateral * py);
778
- }
779
- function packWithWidth(next, width) {
780
- const out = clonePositions(next);
781
- out[out.length - 1] = width;
782
- return out;
783
- }
784
- const axis1DrawRule = {
785
- id: "axis1",
786
- minimumUserPoints: 2,
787
- trailingFixedSlots: 1,
788
- derive: derive$1,
789
- transform(event) {
790
- const { previous, next, activePointIndex } = event;
791
- if (next.length < 3 || previous.length < 3) return derive$1(next);
792
- if (!(activePointIndex === 0 || activePointIndex === 1)) return clonePositions(next);
793
- const metrics = calculateMetrics(previous);
794
- if (!metrics) return clonePositions(next);
795
- const projected = projectWidthIntoFrame(next, metrics.longitudinal, metrics.lateral);
796
- if (!projected) return clonePositions(next);
797
- return packWithWidth(next, projected);
798
- }
799
- };
800
- //#endregion
801
- //#region src/draw-rules/area21.ts
802
- function derive(points) {
803
- if (points.length === 2) return clonePositions([
804
- points[0],
805
- points[1],
806
- points[1]
807
- ]);
808
- return clonePositions(points);
809
- }
810
- const searchAreaDrawRule = {
811
- id: "area21:search-area",
812
- minimumUserPoints: 3,
813
- minimumPreviewPoints: 2,
814
- canonicalPointCount: 3,
815
- derive,
816
- transform(event) {
817
- return derive(event.next);
818
- }
819
- };
820
- //#endregion
821
- //#region src/generators/cm15-maneuver-areas/params.ts
822
- const ATTACK_SHAFT_PARAMS = [
823
- {
824
- key: "shaftWidthRatio",
825
- label: "Shaft width",
826
- description: "Arrow shaft width as a ratio of the arrowhead width.",
827
- type: "number",
828
- min: .1,
829
- max: 1,
830
- step: .05
831
- },
832
- {
833
- key: "roundedBends",
834
- label: "Rounded bends",
835
- description: "Round the corners where the shaft changes direction.",
836
- type: "boolean"
837
- },
838
- {
839
- key: "bendSegments",
840
- label: "Bend segments",
841
- description: "Number of segments approximating each rounded bend.",
842
- type: "number",
843
- min: 1,
844
- max: 20,
845
- step: 1,
846
- visibleWhen: (opts) => opts.roundedBends === true
847
- }
848
- ];
849
- const FIRE_ARROW_PARAMS = [
850
- {
851
- key: "arrowTipWidthRatio",
852
- label: "Arrow tip width",
853
- description: "Width of the arrow tip as a ratio of the head width.",
854
- type: "number",
855
- min: .05,
856
- max: 1,
857
- step: .01
858
- },
859
- {
860
- key: "backLineLengthRatio",
861
- label: "Back line length",
862
- description: "Length of the rear flare lines as a ratio of the head.",
863
- type: "number",
864
- min: .05,
865
- max: 1,
866
- step: .01
867
- },
868
- {
869
- key: "backLineAngle",
870
- label: "Back line angle",
871
- description: "Angle of the rear flare lines, in degrees.",
872
- type: "number",
873
- min: 0,
874
- max: 90,
875
- step: 1
876
- }
877
- ];
878
- const FORTIFIED_PARAMS$1 = [{
879
- key: "sizePixels",
880
- label: "Size",
881
- description: "Size of the fortified-line teeth, in pixels.",
882
- type: "number",
883
- min: 5,
884
- max: 50,
885
- step: 1,
886
- unit: "px"
887
- }, {
888
- key: "size",
889
- label: "Size",
890
- description: "Size of the fortified-line teeth, in meters.",
891
- type: "number",
892
- min: 1,
893
- max: 500,
894
- step: 1,
895
- unit: "m"
896
- }];
897
- //#endregion
898
- //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
899
- const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
900
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
901
- roundedBends: false,
902
- bendSegments: 5
903
- };
904
- const AIRBORNE_ATTACK_METADATA = {
905
- id: "airborne-attack",
906
- name: "Airborne Attack",
907
- description: "Airborne or aviation axis of advance.",
908
- entity: "Maneuver Areas",
909
- entityType: "Axis of Advance",
910
- entitySubtype: "Airborne Attack",
911
- value: "151401",
912
- minCoordinates: 3,
913
- geometry: "line",
914
- geometryTypes: ["LineString"],
915
- drawRule: "Axis1",
916
- params: ATTACK_SHAFT_PARAMS
917
- };
918
- /**
919
- * Generates a GeoJSON FeatureCollection for an Airborne Attack tactical symbol.
920
- *
921
- * The symbol is similar to Supporting Attack but features a crossover point
922
- * between the shaft and the head (Points 1 and 2/Neck).
923
- *
924
- * @param coordinates - An array of GeoJSON Positions.
925
- * @param options - Configuration options.
926
- * @returns A GeoJSON FeatureCollection<LineString>.
927
- */
928
- function createAirborneAttack(coordinates, options = {}) {
929
- const { geometry } = processAttackGeometry(coordinates, options);
930
- if (!geometry) return {
931
- type: "FeatureCollection",
932
- features: []
933
- };
934
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
935
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
936
- return {
937
- type: "FeatureCollection",
938
- features: [{
939
- type: "Feature",
940
- properties: {},
941
- geometry: {
942
- type: "LineString",
943
- coordinates: [
944
- ...shaftLeftToNeck,
945
- geometry.headRing[3],
946
- geometry.headRing[2],
947
- geometry.headRing[1],
948
- geometry.headRing[0],
949
- geometry.headRing[5],
950
- ...[...shaftRightToNeck].reverse()
951
- ].map((p) => unproject(p[0], p[1]))
952
- }
953
- }]
954
- };
955
- }
956
- const AIRBORNE_ATTACK = defineControlMeasure({
957
- metadata: AIRBORNE_ATTACK_METADATA,
958
- generator: createAirborneAttack,
959
- defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
960
- rule: axis1DrawRule
961
- });
962
- /**
963
- * Reacts to an **input-contract** violation according to `mode`: `throw`
964
- * raises, `warn` logs, `silent` (the default) does neither. Always returns
965
- * `false` so a caller can `return reportValidationIssue(...)` from a guard.
966
- *
967
- * Governs the *input contract* only (control-point count and finiteness),
968
- * checked once at the render seam — not geometric degeneracy, which always
969
- * yields an empty render silently. See ADR-0014 and the `CONTEXT.md` terms.
970
- */
971
- function reportValidationIssue(mode, message) {
972
- const resolved = mode ?? "silent";
973
- if (resolved === "throw") throw new Error(message);
974
- if (resolved === "warn") console.warn(message);
975
- return false;
976
- }
977
- /**
978
- * Narrows a control-point array to the fixed `[PT1, PT2, PT3]` tuple the ambush
979
- * and search-area generators require. The render seam has already validated
980
- * that at least three valid points are present (ADR-0014), so this is a pure
981
- * cast with no runtime guard. Used by those measures' definition adapters so
982
- * the generators keep their precise tuple parameter (ADR-0013).
983
- */
984
- function asThreePoints(points) {
985
- return [
986
- points[0],
987
- points[1],
988
- points[2]
989
- ];
990
- }
991
- const DEFAULT_AMBUSH_OPTIONS = {
992
- hSize: .15,
993
- hWidth: .5,
994
- hLen: .15,
995
- hCount: 5,
996
- cDepth: .25,
997
- resolution: 30
998
- };
999
- const AMBUSH_METADATA = {
1000
- id: "ambush",
1001
- name: "Ambush",
1002
- description: "A variation of attack from concealed positions against a moving or temporarily halted enemy.",
1003
- entity: "Maneuver Lines",
1004
- entityType: "Ambush",
1005
- value: "141700",
1006
- minCoordinates: 3,
1007
- maxCoordinates: 3,
1008
- geometry: "line",
1009
- geometryTypes: ["MultiLineString"],
1010
- drawRule: "Line29",
1011
- params: [
1012
- {
1013
- key: "hSize",
1014
- label: "Head size",
1015
- description: "Arrowhead length as a fraction of the chord span",
1016
- type: "number",
1017
- min: .01,
1018
- max: .5,
1019
- step: .01
1020
- },
1021
- {
1022
- key: "hWidth",
1023
- label: "Head width",
1024
- description: "Arrowhead wing spread relative to its length",
1025
- type: "number",
1026
- min: .05,
1027
- max: 2,
1028
- step: .01
1029
- },
1030
- {
1031
- key: "hLen",
1032
- label: "Hatch length",
1033
- description: "Length of the parallel hatches as a fraction of the chord span",
1034
- type: "number",
1035
- min: .01,
1036
- max: .5,
1037
- step: .01
1038
- },
1039
- {
1040
- key: "hCount",
1041
- label: "Hatch count",
1042
- description: "Number of parallel hatches spaced along the curve",
1043
- type: "number",
1044
- min: 0,
1045
- max: 20,
1046
- step: 1
1047
- },
1048
- {
1049
- key: "cDepth",
1050
- label: "Curve depth",
1051
- description: "How far the back line bends toward the tip, as a fraction of the chord span",
1052
- type: "number",
1053
- min: 0,
1054
- max: 1,
1055
- step: .01
1056
- },
1057
- {
1058
- key: "resolution",
1059
- label: "Resolution",
1060
- description: "Number of segments used to render the curve",
1061
- type: "number",
1062
- min: 4,
1063
- max: 96,
1064
- step: 1
1065
- }
1066
- ]
1067
- };
1068
- const quadBezier = (t, a, b, c) => {
1069
- const it = 1 - t;
1070
- return [it * it * a[0] + 2 * it * t * b[0] + t * t * c[0], it * it * a[1] + 2 * it * t * b[1] + t * t * c[1]];
1071
- };
1072
- /**
1073
- * Generates a GeoJSON FeatureCollection for an AMBUSH tactical symbol.
1074
- * Handles internal projection to maintain strict perpendicularity and parallel hatching.
1075
- *
1076
- * @param coordinates - [PT 1 (Target Tip), PT 2 (Back Chord End), PT 3 (Back Chord End)]
1077
- * @param options - Geometric and styling parameters
1078
- * @returns GeoJSON FeatureCollection containing LineString components
1079
- */
1080
- function createAmbushSymbol(coordinates, options = {}) {
1081
- const { hSize, hWidth, hLen, hCount, cDepth, resolution } = {
1082
- ...DEFAULT_AMBUSH_OPTIONS,
1083
- ...options
1084
- };
1085
- const safeResolution = Math.max(1, Math.floor(resolution));
1086
- const safeHCount = Math.max(0, Math.floor(hCount));
1087
- const safeHSize = Math.max(0, hSize);
1088
- const safeHWidth = Math.max(0, hWidth);
1089
- const safeHLen = Math.max(0, hLen);
1090
- const safeCDepth = Math.max(0, cDepth);
1091
- const [p1, p2, p3] = coordinates.map((coord) => project(coord[0], coord[1]));
1092
- const mid = vecScale(vecAdd(p2, p3), .5);
1093
- const v23 = vecSub(p3, p2);
1094
- const span = vecMag(v23);
1095
- if (span < 1e-6) return {
1096
- type: "FeatureCollection",
1097
- features: []
1098
- };
1099
- const dir23 = vecNorm(v23);
1100
- const n = [-dir23[1], dir23[0]];
1101
- const dist = vecDot(vecSub(p1, mid), n);
1102
- const side = dist >= 0 ? 1 : -1;
1103
- const ctrl = vecAdd(mid, vecScale(n, side * span * safeCDepth));
1104
- const shaftOrigin = quadBezier(.5, p2, ctrl, p3);
1105
- const adjP1 = vecAdd(shaftOrigin, vecScale(n, side * Math.max(0, Math.abs(dist) - span * safeCDepth * .5)));
1106
- const lines = [];
1107
- lines.push([shaftOrigin, adjP1]);
1108
- const hL = span * safeHSize;
1109
- const hw = hL * safeHWidth;
1110
- const hBase = vecSub(adjP1, vecScale(n, side * hL));
1111
- const perp = [-n[1], n[0]];
1112
- lines.push([
1113
- vecAdd(hBase, vecScale(perp, hw)),
1114
- adjP1,
1115
- vecSub(hBase, vecScale(perp, hw))
1116
- ]);
1117
- const curve = [];
1118
- for (let i = 0; i <= safeResolution; i++) {
1119
- const t = i / safeResolution;
1120
- curve.push(quadBezier(t, p2, ctrl, p3));
1121
- }
1122
- lines.push(curve);
1123
- const hLenActual = span * safeHLen;
1124
- for (let i = 1; i <= safeHCount; i++) {
1125
- const pt = quadBezier(i / (safeHCount + 1), p2, ctrl, p3);
1126
- lines.push([pt, vecSub(pt, vecScale(n, side * hLenActual))]);
1127
- }
1128
- return {
1129
- type: "FeatureCollection",
1130
- features: [{
1131
- type: "Feature",
1132
- properties: {},
1133
- geometry: {
1134
- type: "MultiLineString",
1135
- coordinates: lines.map((line) => line.map((c) => unproject(c[0], c[1])))
1136
- }
1137
- }]
1138
- };
1139
- }
1140
- const AMBUSH = defineControlMeasure({
1141
- metadata: AMBUSH_METADATA,
1142
- generator: (controlPoints, options) => createAmbushSymbol(asThreePoints(controlPoints), options),
1143
- defaultOptions: DEFAULT_AMBUSH_OPTIONS,
1144
- rule: ambushDrawRule
1145
- });
1146
- //#endregion
1147
- //#region src/generators/cm29-protection-lines/params.ts
1148
- const FORTIFIED_PARAMS = [{
1149
- key: "sizePixels",
1150
- label: "Size",
1151
- description: "Side length of the square crenellations",
1152
- type: "number",
1153
- min: 5,
1154
- max: 50,
1155
- step: 1,
1156
- unit: "px"
1157
- }, {
1158
- key: "size",
1159
- label: "Size",
1160
- description: "Side length of the square crenellations",
1161
- type: "number",
1162
- min: 1,
1163
- max: 500,
1164
- step: 1,
1165
- unit: "m"
1166
- }];
1167
- const ANTITANK_DITCH_PARAMS = [
1168
- {
1169
- key: "toothHeightPixels",
1170
- label: "Tooth height",
1171
- description: "Perpendicular depth of each triangular tooth",
1172
- type: "number",
1173
- min: 5,
1174
- max: 50,
1175
- step: 1,
1176
- unit: "px"
1177
- },
1178
- {
1179
- key: "toothHeight",
1180
- label: "Tooth height",
1181
- description: "Perpendicular depth of each triangular tooth",
1182
- type: "number",
1183
- min: 1,
1184
- max: 500,
1185
- step: 1,
1186
- unit: "m"
1187
- },
1188
- {
1189
- key: "toothWidthRatio",
1190
- label: "Tooth width ratio",
1191
- description: "Tooth base span as a multiple of height",
1192
- type: "number",
1193
- min: .5,
1194
- max: 3,
1195
- step: .05,
1196
- unit: "x"
1197
- }
1198
- ];
1199
- const ANTITANK_WALL_PARAMS = [...ANTITANK_DITCH_PARAMS, {
1200
- key: "toothSpacingRatio",
1201
- label: "Tooth spacing ratio",
1202
- description: "Gap between teeth as a multiple of base width",
1203
- type: "number",
1204
- min: 0,
1205
- max: 3,
1206
- step: .05,
1207
- unit: "x"
1208
- }];
1209
- //#endregion
1210
- //#region src/generators/cm29-protection-lines/antitankDitch.ts
1211
- const DEFAULT_TOOTH_HEIGHT = 50;
1212
- const DEFAULT_TOOTH_HEIGHT_PIXELS = 10;
1213
- const DEFAULT_TOOTH_WIDTH_RATIO$1 = 1.15;
1214
- const DEFAULT_ANTITANK_DITCH_OPTIONS = {
1215
- toothHeight: DEFAULT_TOOTH_HEIGHT,
1216
- toothHeightPixels: DEFAULT_TOOTH_HEIGHT_PIXELS,
1217
- toothWidthRatio: DEFAULT_TOOTH_WIDTH_RATIO$1
1218
- };
1219
- const ANTITANK_DITCH_UNDER_CONSTRUCTION_METADATA = {
1220
- id: "antitank-ditch-under-construction",
1221
- name: "Ditch - Under Construction",
1222
- description: "Antitank ditch obstacle under construction.",
1223
- entity: "Protection Lines",
1224
- entityType: "Antitank Obstacles",
1225
- entitySubtype: "Ditch - Under Construction",
1226
- value: "290201",
1227
- minCoordinates: 2,
1228
- geometry: "line",
1229
- geometryTypes: ["MultiLineString"],
1230
- drawRule: "Line1",
1231
- params: ANTITANK_DITCH_PARAMS
1232
- };
1233
- const ANTITANK_DITCH_COMPLETED_METADATA = {
1234
- id: "antitank-ditch-completed",
1235
- name: "Ditch - Completed",
1236
- description: "Completed antitank ditch obstacle.",
1237
- entity: "Protection Lines",
1238
- entityType: "Antitank Obstacles",
1239
- entitySubtype: "Ditch - Completed",
1240
- value: "290202",
1241
- minCoordinates: 2,
1242
- geometry: "line",
1243
- geometryTypes: ["MultiPolygon"],
1244
- drawRule: "Line1",
1245
- params: ANTITANK_DITCH_PARAMS
1246
- };
1247
- function calculateAntitankDitchHeight(options) {
1248
- const { toothHeight = DEFAULT_TOOTH_HEIGHT, toothHeightPixels, metersPerPixel } = options;
1249
- if (toothHeightPixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) return Math.max(EPSILON, toothHeightPixels * metersPerPixel);
1250
- return Math.max(EPSILON, toothHeight);
1251
- }
1252
- function generateAntitankDitchTriangles(projectedPoints, toothHeight, toothWidthRatio = DEFAULT_TOOTH_WIDTH_RATIO$1) {
1253
- const triangles = [];
1254
- const targetPeriod = Math.max(EPSILON, toothWidthRatio) * toothHeight;
1255
- for (let i = 0; i < projectedPoints.length - 1; i++) {
1256
- const p1 = projectedPoints[i];
1257
- const p2 = projectedPoints[i + 1];
1258
- const segmentVec = vecSub(p2, p1);
1259
- const segmentLength = vecMag(segmentVec);
1260
- if (segmentLength < 1e-6) continue;
1261
- const segmentDir = vecNorm(segmentVec);
1262
- const perpDir = [-segmentDir[1], segmentDir[0]];
1263
- const toothCount = Math.max(1, Math.round(segmentLength / targetPeriod));
1264
- const period = segmentLength / toothCount;
1265
- for (let toothIndex = 0; toothIndex < toothCount; toothIndex++) {
1266
- const baseStart = vecAdd(p1, vecScale(segmentDir, period * toothIndex));
1267
- const baseEnd = vecAdd(p1, vecScale(segmentDir, period * (toothIndex + 1)));
1268
- const tip = vecAdd(vecAdd(baseStart, vecScale(segmentDir, period / 2)), vecScale(perpDir, toothHeight));
1269
- triangles.push([
1270
- baseStart,
1271
- tip,
1272
- baseEnd
1273
- ]);
1274
- }
1275
- }
1276
- return triangles;
1277
- }
1278
- function buildAntitankDitchTriangles(positions, options) {
1279
- const toothHeight = calculateAntitankDitchHeight(options);
1280
- const toothWidthRatio = options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO$1;
1281
- return generateAntitankDitchTriangles(positions.map((position) => project(position[0], position[1])), toothHeight, toothWidthRatio);
1282
- }
1283
- function createAntitankDitchUnderConstruction(positions, options = {}) {
1284
- const triangles = buildAntitankDitchTriangles(positions, options);
1285
- return {
1286
- type: "FeatureCollection",
1287
- features: [{
1288
- type: "Feature",
1289
- properties: { part: "ditch" },
1290
- geometry: {
1291
- type: "MultiLineString",
1292
- coordinates: [positions.map((position) => [position[0], position[1]]), ...triangles.map((triangle) => triangle.map((point) => unproject(point[0], point[1])))]
1293
- }
1294
- }]
1295
- };
1296
- }
1297
- function createAntitankDitchCompleted(positions, options = {}) {
1298
- return {
1299
- type: "FeatureCollection",
1300
- features: [{
1301
- type: "Feature",
1302
- properties: {
1303
- part: "teeth",
1304
- fill: true
1305
- },
1306
- geometry: {
1307
- type: "MultiPolygon",
1308
- coordinates: buildAntitankDitchTriangles(positions, options).map((triangle) => {
1309
- return [[...triangle, triangle[0]].map((point) => unproject(point[0], point[1]))];
1310
- })
1311
- }
1312
- }]
1313
- };
1314
- }
1315
- const ANTITANK_DITCH_UNDER_CONSTRUCTION = defineControlMeasure({
1316
- metadata: ANTITANK_DITCH_UNDER_CONSTRUCTION_METADATA,
1317
- generator: createAntitankDitchUnderConstruction,
1318
- defaultOptions: DEFAULT_ANTITANK_DITCH_OPTIONS,
1319
- rule: line1DrawRule
1320
- });
1321
- const ANTITANK_DITCH_COMPLETED = defineControlMeasure({
1322
- metadata: ANTITANK_DITCH_COMPLETED_METADATA,
1323
- generator: createAntitankDitchCompleted,
1324
- defaultOptions: DEFAULT_ANTITANK_DITCH_OPTIONS,
1325
- rule: line1DrawRule
1326
- });
1327
- //#endregion
1328
- //#region src/generators/cm29-protection-lines/antitankWall.ts
1329
- const DEFAULT_TOOTH_SPACING_RATIO = 1.35;
1330
- const DEFAULT_TOOTH_WIDTH_RATIO = 2.2;
1331
- const DEFAULT_ANTITANK_WALL_OPTIONS = {
1332
- ...DEFAULT_ANTITANK_DITCH_OPTIONS,
1333
- toothWidthRatio: DEFAULT_TOOTH_WIDTH_RATIO,
1334
- toothSpacingRatio: DEFAULT_TOOTH_SPACING_RATIO
1335
- };
1336
- const ANTITANK_WALL_METADATA = {
1337
- id: "antitank-wall",
1338
- name: "Antitank Wall",
1339
- description: "Antitank wall obstacle.",
1340
- entity: "Protection Lines",
1341
- entityType: "Antitank Obstacles",
1342
- entitySubtype: "Antitank Wall",
1343
- value: "290204",
1344
- minCoordinates: 2,
1345
- geometry: "line",
1346
- geometryTypes: ["MultiLineString"],
1347
- drawRule: "Line1",
1348
- params: ANTITANK_WALL_PARAMS
1349
- };
1350
- function generateAntitankWallPoints(projectedPoints, toothHeight, toothWidthRatio = DEFAULT_TOOTH_WIDTH_RATIO, toothSpacingRatio = DEFAULT_TOOTH_SPACING_RATIO) {
1351
- if (projectedPoints.length < 2) return projectedPoints;
1352
- const wallPoints = [];
1353
- const safeToothHeight = Math.max(EPSILON, toothHeight);
1354
- const safeToothWidthRatio = Math.max(EPSILON, toothWidthRatio);
1355
- const safeToothSpacingRatio = Math.max(0, toothSpacingRatio);
1356
- const targetPeriod = safeToothWidthRatio * safeToothHeight;
1357
- for (let i = 0; i < projectedPoints.length - 1; i++) {
1358
- const p1 = projectedPoints[i];
1359
- const p2 = projectedPoints[i + 1];
1360
- const segmentVec = vecSub(p2, p1);
1361
- const segmentLength = vecMag(segmentVec);
1362
- if (wallPoints.length === 0) wallPoints.push(p1);
1363
- if (segmentLength < 1e-6) continue;
1364
- const segmentDir = vecNorm(segmentVec);
1365
- const perpDir = [segmentDir[1], -segmentDir[0]];
1366
- const toothCount = Math.max(1, Math.round(segmentLength / targetPeriod));
1367
- const period = segmentLength / toothCount;
1368
- const toothBase = Math.max(EPSILON, period / (1 + safeToothSpacingRatio));
1369
- const shoulder = (period - toothBase) / 2;
1370
- for (let toothIndex = 0; toothIndex < toothCount; toothIndex++) {
1371
- const baseStartDistance = period * toothIndex + shoulder;
1372
- const baseStart = vecAdd(p1, vecScale(segmentDir, baseStartDistance));
1373
- const baseEnd = vecAdd(p1, vecScale(segmentDir, baseStartDistance + toothBase));
1374
- const tip = vecAdd(vecAdd(baseStart, vecScale(segmentDir, toothBase / 2)), vecScale(perpDir, safeToothHeight));
1375
- wallPoints.push(baseStart, tip, baseEnd);
1376
- }
1377
- wallPoints.push(p2);
1378
- }
1379
- return wallPoints;
1380
- }
1381
- function createAntitankWall(positions, options = {}) {
1382
- const toothHeight = calculateAntitankDitchHeight(options);
1383
- const toothWidthRatio = options.toothWidthRatio ?? DEFAULT_TOOTH_WIDTH_RATIO;
1384
- const toothSpacingRatio = options.toothSpacingRatio ?? DEFAULT_TOOTH_SPACING_RATIO;
1385
- return {
1386
- type: "FeatureCollection",
1387
- features: [{
1388
- type: "Feature",
1389
- properties: { part: "wall" },
1390
- geometry: {
1391
- type: "MultiLineString",
1392
- coordinates: [generateAntitankWallPoints(positions.map((position) => project(position[0], position[1])), toothHeight, toothWidthRatio, toothSpacingRatio).map((point) => unproject(point[0], point[1]))]
1393
- }
1394
- }]
1395
- };
1396
- }
1397
- const ANTITANK_WALL = defineControlMeasure({
1398
- metadata: ANTITANK_WALL_METADATA,
1399
- generator: createAntitankWall,
1400
- defaultOptions: DEFAULT_ANTITANK_WALL_OPTIONS,
1401
- rule: line1DrawRule
1402
- });
1403
- //#endregion
1404
- //#region src/generators/cm15-maneuver-areas/attackByFire.ts
1405
- const DEFAULT_ATTACK_BY_FIRE_OPTIONS = {
1406
- arrowTipWidthRatio: .3,
1407
- backLineLengthRatio: .3,
1408
- backLineAngle: 45
1409
- };
1410
- const ATTACK_BY_FIRE_METADATA = {
1411
- id: "attack-by-fire",
1412
- name: "Attack by Fire",
1413
- description: "Attack by fire graphic with base, shaft, and arrowhead.",
1414
- entity: "Maneuver Areas",
1415
- entityType: "Attack by Fire",
1416
- value: "152000",
1417
- minCoordinates: 3,
1418
- geometry: "line",
1419
- geometryTypes: ["LineString"],
1420
- drawRule: "Area7",
1421
- params: FIRE_ARROW_PARAMS
1422
- };
1423
- /**
1424
- * Generates a GeoJSON FeatureCollection for an Attack By Fire tactical graphic.
1425
- *
1426
- * Input:
1427
- * - PT1: Arrow Tip (Target)
1428
- * - PT2: Left Base
1429
- * - PT3: Right Base
1430
- *
1431
- * Logic:
1432
- * - Shaft: Midpoint(PT2, PT3) -> PT1
1433
- * - Base Line: PT2 -> PT3
1434
- * - Wings: Splayed out from PT2 and PT3
1435
- */
1436
- function createAttackByFire(coordinates, options = {}) {
1437
- const featureProps = {};
1438
- const arrowTipWidthRatio = options.arrowTipWidthRatio ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.arrowTipWidthRatio;
1439
- const backLineLengthRatio = options.backLineLengthRatio ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.backLineLengthRatio;
1440
- const backLineAngle = options.backLineAngle ?? DEFAULT_ATTACK_BY_FIRE_OPTIONS.backLineAngle;
1441
- const c1 = coordinates[0];
1442
- const c2 = coordinates[1];
1443
- const c3 = coordinates[2];
1444
- const p1 = project(c1[0], c1[1]);
1445
- const p2 = project(c2[0], c2[1]);
1446
- const p3 = project(c3[0], c3[1]);
1447
- const frame = createProjectedBaselineFrame(p2, p3, {
1448
- origin: "midpoint",
1449
- normal: "right"
1450
- });
1451
- if (!frame) return {
1452
- type: "FeatureCollection",
1453
- features: []
1454
- };
1455
- const pMid = frame.origin;
1456
- const signedTipDistance = vecDot(vecSub(p1, pMid), frame.normal);
1457
- if (Math.abs(signedTipDistance) < 1e-6) return {
1458
- type: "FeatureCollection",
1459
- features: []
1460
- };
1461
- const pTip = vecAdd(pMid, vecScale(frame.normal, signedTipDistance));
1462
- const dirShaft = signedTipDistance >= 0 ? frame.normal : vecScale(frame.normal, -1);
1463
- const lenShaft = Math.abs(signedTipDistance);
1464
- const lenBase = frame.length;
1465
- const dirBase = frame.direction;
1466
- const angleRad = backLineAngle * Math.PI / 180;
1467
- const baseAngle = -Math.PI / 2;
1468
- const angleLeft = baseAngle - angleRad;
1469
- const dirBackLeft = [dirBase[0] * Math.cos(angleLeft) - dirBase[1] * Math.sin(angleLeft), dirBase[0] * Math.sin(angleLeft) + dirBase[1] * Math.cos(angleLeft)];
1470
- const angleRight = baseAngle + angleRad;
1471
- const dirBackRight = [dirBase[0] * Math.cos(angleRight) - dirBase[1] * Math.sin(angleRight), dirBase[0] * Math.sin(angleRight) + dirBase[1] * Math.cos(angleRight)];
1472
- const backLen = lenBase * backLineLengthRatio;
1473
- const p2Back = vecAdd(p2, vecScale(dirBackLeft, backLen));
1474
- const p3Back = vecAdd(p3, vecScale(dirBackRight, backLen));
1475
- const headWidth = lenShaft * arrowTipWidthRatio;
1476
- const headFeatures = createArrowHead$2(pTip, dirShaft, headWidth, headWidth);
1477
- return {
1478
- type: "FeatureCollection",
1479
- features: [
1480
- {
1481
- type: "Feature",
1482
- properties: featureProps,
1483
- geometry: {
1484
- type: "LineString",
1485
- coordinates: [unproject(p2[0], p2[1]), unproject(p2Back[0], p2Back[1])]
1486
- }
1487
- },
1488
- {
1489
- type: "Feature",
1490
- properties: featureProps,
1491
- geometry: {
1492
- type: "LineString",
1493
- coordinates: [unproject(p3[0], p3[1]), unproject(p3Back[0], p3Back[1])]
1494
- }
1495
- },
1496
- {
1497
- type: "Feature",
1498
- properties: featureProps,
1499
- geometry: {
1500
- type: "LineString",
1501
- coordinates: [unproject(pMid[0], pMid[1]), unproject(pTip[0], pTip[1])]
1502
- }
1503
- },
1504
- {
1505
- type: "Feature",
1506
- properties: featureProps,
1507
- geometry: {
1508
- type: "LineString",
1509
- coordinates: [unproject(p2[0], p2[1]), unproject(p3[0], p3[1])]
1510
- }
1511
- },
1512
- ...headFeatures.map((f) => ({
1513
- ...f,
1514
- properties: featureProps
1515
- }))
1516
- ]
1517
- };
1518
- }
1519
- function createArrowHead$2(tip, dir, length, width) {
1520
- const norm = [-dir[1], dir[0]];
1521
- const base = vecSub(tip, vecScale(dir, length));
1522
- const left = vecAdd(base, vecScale(norm, width / 2));
1523
- const right = vecSub(base, vecScale(norm, width / 2));
1524
- return [{
1525
- type: "Feature",
1526
- geometry: {
1527
- type: "LineString",
1528
- coordinates: [
1529
- unproject(left[0], left[1]),
1530
- unproject(tip[0], tip[1]),
1531
- unproject(right[0], right[1])
1532
- ]
1533
- },
1534
- properties: {}
1535
- }];
1536
- }
1537
- const ATTACK_BY_FIRE = defineControlMeasure({
1538
- metadata: ATTACK_BY_FIRE_METADATA,
1539
- generator: createAttackByFire,
1540
- defaultOptions: DEFAULT_ATTACK_BY_FIRE_OPTIONS,
1541
- rule: attackByFireDrawRule
1542
- });
1543
- //#endregion
1544
- //#region src/generators/cm15-maneuver-areas/attackHelicopter.ts
1545
- const DEFAULT_ATTACK_HELICOPTER_OPTIONS = {
1546
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
1547
- roundedBends: false,
1548
- bendSegments: 5,
1549
- symbolHeightRatio: .45,
1550
- triangleSizeRatio: .25,
1551
- bottomBarWidthRatio: 1.5,
1552
- bowtieWidthRatio: .8
1553
- };
1554
- const ATTACK_HELICOPTER_METADATA = {
1555
- id: "attack-helicopter",
1556
- name: "Attack Helicopter",
1557
- description: "Attack helicopter axis of advance.",
1558
- entity: "Maneuver Areas",
1559
- entityType: "Axis of Advance",
1560
- entitySubtype: "Attack Helicopter",
1561
- value: "151402",
1562
- minCoordinates: 3,
1563
- geometry: "line",
1564
- geometryTypes: ["LineString", "Polygon"],
1565
- drawRule: "Axis1",
1566
- params: [
1567
- ...ATTACK_SHAFT_PARAMS,
1568
- {
1569
- key: "symbolHeightRatio",
1570
- label: "Symbol height",
1571
- description: "Height of the helicopter symbol as a ratio of the head width.",
1572
- type: "number",
1573
- min: .1,
1574
- max: 1.5,
1575
- step: .05
1576
- },
1577
- {
1578
- key: "triangleSizeRatio",
1579
- label: "Triangle size",
1580
- description: "Size of the top triangle relative to the reference unit.",
1581
- type: "number",
1582
- min: .1,
1583
- max: .8,
1584
- step: .05
1585
- },
1586
- {
1587
- key: "bottomBarWidthRatio",
1588
- label: "Bottom bar width",
1589
- description: "Width of the bottom bar relative to the reference triangle.",
1590
- type: "number",
1591
- min: .5,
1592
- max: 3,
1593
- step: .1
1594
- },
1595
- {
1596
- key: "bowtieWidthRatio",
1597
- label: "Bowtie width",
1598
- description: "Size of the central bowtie relative to the reference triangle.",
1599
- type: "number",
1600
- min: .2,
1601
- max: 2,
1602
- step: .1
1603
- }
1604
- ]
1605
- };
1606
- /**
1607
- * Generates a GeoJSON FeatureCollection for an Attack Helicopter tactical symbol.
1608
- *
1609
- * The symbol is based on the Airborne Attack (bow tie) geometry but features
1610
- * an additional symbol (vertical line + triangle) at the crossover point.
1611
- *
1612
- * @param coordinates - An array of GeoJSON Positions.
1613
- * @param options - Configuration options.
1614
- * @returns A GeoJSON FeatureCollection containing the main graphic and the symbol.
1615
- */
1616
- function createAttackHelicopter(coordinates, options = {}) {
1617
- const { geometry } = processAttackGeometry(coordinates, options);
1618
- if (!geometry) return {
1619
- type: "FeatureCollection",
1620
- features: []
1621
- };
1622
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
1623
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
1624
- if (geometry.shaftLeft.length < 2 || geometry.shaftRight.length < 2) return {
1625
- type: "FeatureCollection",
1626
- features: []
1627
- };
1628
- const pL = geometry.shaftLeft[geometry.shaftLeft.length - 2];
1629
- const pIR = geometry.headRing[3];
1630
- const pR = geometry.shaftRight[geometry.shaftRight.length - 2];
1631
- const pIL = geometry.headRing[5];
1632
- if (!pL || !pIR || !pR || !pIL) return {
1633
- type: "FeatureCollection",
1634
- features: []
1635
- };
1636
- const center = lineIntersection(pL, pIR, pR, pIL);
1637
- const features = [{
1638
- type: "Feature",
1639
- properties: {},
1640
- geometry: {
1641
- type: "LineString",
1642
- coordinates: [
1643
- ...shaftLeftToNeck,
1644
- pIR,
1645
- geometry.headRing[2],
1646
- geometry.headRing[1],
1647
- geometry.headRing[0],
1648
- pIL,
1649
- ...[...shaftRightToNeck].reverse()
1650
- ].map((p) => unproject(p[0], p[1]))
1651
- }
1652
- }];
1653
- if (center) {
1654
- const neck = geometry.ptNeck;
1655
- const tip = geometry.ptTip;
1656
- const d = vecSub(tip, neck);
1657
- if (vecMag(d) > 0) {
1658
- const u = vecNorm(d);
1659
- const n = [-u[1], u[0]];
1660
- const headWidth = Math.sqrt(Math.pow(geometry.headRing[0][0] - geometry.headRing[2][0], 2) + Math.pow(geometry.headRing[0][1] - geometry.headRing[2][1], 2));
1661
- const symbolHeightRatio = options.symbolHeightRatio ?? DEFAULT_ATTACK_HELICOPTER_OPTIONS.symbolHeightRatio;
1662
- const triangleSizeRatio = options.triangleSizeRatio ?? DEFAULT_ATTACK_HELICOPTER_OPTIONS.triangleSizeRatio;
1663
- const bottomBarWidthRatio = options.bottomBarWidthRatio ?? DEFAULT_ATTACK_HELICOPTER_OPTIONS.bottomBarWidthRatio;
1664
- const bowtieWidthRatio = options.bowtieWidthRatio ?? DEFAULT_ATTACK_HELICOPTER_OPTIONS.bowtieWidthRatio;
1665
- const baseUnit = headWidth * .45;
1666
- const shaftLength = headWidth * symbolHeightRatio;
1667
- const triHeight = baseUnit * triangleSizeRatio;
1668
- const triHalfWidth = triHeight * .8;
1669
- const refTriHeight = baseUnit * .25;
1670
- const refTriHalfWidth = refTriHeight * .8;
1671
- const lineLengthUp = shaftLength - triHeight;
1672
- const lineLengthDown = shaftLength;
1673
- const pLineTop = vecAdd(center, vecScale(n, lineLengthUp));
1674
- const pLineBottom = vecSub(center, vecScale(n, lineLengthDown));
1675
- const verticalLineFeature = {
1676
- type: "Feature",
1677
- properties: {},
1678
- geometry: {
1679
- type: "LineString",
1680
- coordinates: [unproject(pLineTop[0], pLineTop[1]), unproject(pLineBottom[0], pLineBottom[1])]
1681
- }
1682
- };
1683
- const pTriTip = vecAdd(pLineTop, vecScale(n, triHeight));
1684
- const pTriLeft = vecAdd(pLineTop, vecScale(u, triHalfWidth));
1685
- const pTriRight = vecSub(pLineTop, vecScale(u, triHalfWidth));
1686
- const triFeature = {
1687
- type: "Feature",
1688
- properties: { fill: "currentColor" },
1689
- geometry: {
1690
- type: "Polygon",
1691
- coordinates: [[
1692
- unproject(pTriTip[0], pTriTip[1]),
1693
- unproject(pTriRight[0], pTriRight[1]),
1694
- unproject(pTriLeft[0], pTriLeft[1]),
1695
- unproject(pTriTip[0], pTriTip[1])
1696
- ]]
1697
- }
1698
- };
1699
- const barHalfWidth = refTriHalfWidth * bottomBarWidthRatio;
1700
- const pBarLeft = vecAdd(pLineBottom, vecScale(u, barHalfWidth));
1701
- const pBarRight = vecSub(pLineBottom, vecScale(u, barHalfWidth));
1702
- const barFeature = {
1703
- type: "Feature",
1704
- properties: {},
1705
- geometry: {
1706
- type: "LineString",
1707
- coordinates: [unproject(pBarLeft[0], pBarLeft[1]), unproject(pBarRight[0], pBarRight[1])]
1708
- }
1709
- };
1710
- const bowTieSize = refTriHeight * bowtieWidthRatio;
1711
- const bowTieHalfWidth = bowTieSize * .6;
1712
- const pBowTieRightBaseCenter = vecAdd(center, vecScale(u, bowTieSize));
1713
- const pBowTieRightTop = vecAdd(pBowTieRightBaseCenter, vecScale(n, bowTieHalfWidth));
1714
- const pBowTieRightBottom = vecSub(pBowTieRightBaseCenter, vecScale(n, bowTieHalfWidth));
1715
- const bowTieRightFeature = {
1716
- type: "Feature",
1717
- properties: { fill: "currentColor" },
1718
- geometry: {
1719
- type: "Polygon",
1720
- coordinates: [[
1721
- unproject(center[0], center[1]),
1722
- unproject(pBowTieRightTop[0], pBowTieRightTop[1]),
1723
- unproject(pBowTieRightBottom[0], pBowTieRightBottom[1]),
1724
- unproject(center[0], center[1])
1725
- ]]
1726
- }
1727
- };
1728
- const pBowTieLeftBaseCenter = vecSub(center, vecScale(u, bowTieSize));
1729
- const pBowTieLeftTop = vecAdd(pBowTieLeftBaseCenter, vecScale(n, bowTieHalfWidth));
1730
- const pBowTieLeftBottom = vecSub(pBowTieLeftBaseCenter, vecScale(n, bowTieHalfWidth));
1731
- const bowTieLeftFeature = {
1732
- type: "Feature",
1733
- properties: { fill: "currentColor" },
1734
- geometry: {
1735
- type: "Polygon",
1736
- coordinates: [[
1737
- unproject(center[0], center[1]),
1738
- unproject(pBowTieLeftTop[0], pBowTieLeftTop[1]),
1739
- unproject(pBowTieLeftBottom[0], pBowTieLeftBottom[1]),
1740
- unproject(center[0], center[1])
1741
- ]]
1742
- }
1743
- };
1744
- features.push(verticalLineFeature, triFeature, barFeature, bowTieRightFeature, bowTieLeftFeature);
1745
- }
1746
- }
1747
- return {
1748
- type: "FeatureCollection",
1749
- features
1750
- };
1751
- }
1752
- const ATTACK_HELICOPTER = defineControlMeasure({
1753
- metadata: ATTACK_HELICOPTER_METADATA,
1754
- generator: createAttackHelicopter,
1755
- defaultOptions: DEFAULT_ATTACK_HELICOPTER_OPTIONS,
1756
- rule: axis1DrawRule
1757
- });
1758
- //#endregion
1759
- //#region src/generators/cm27-protection-areas/block.ts
1760
- const DEFAULT_STEM_RATIO$1 = 1.5;
1761
- const BLOCK_METADATA = {
1762
- id: "block",
1763
- name: "Block",
1764
- description: "An obstacle effect that integrates fire planning and obstacle effort to stop an attacker along a specific avenue of approach or prevent the attacking force from passing through an engagement area. (FM 3-90)",
1765
- entity: "Protection Areas",
1766
- entityType: "Obstacle Effects",
1767
- entitySubtype: "Block",
1768
- value: "270501",
1769
- minCoordinates: 2,
1770
- geometry: "line",
1771
- geometryTypes: ["LineString"],
1772
- drawRule: "Area11"
1773
- };
1774
- const DEFAULT_BLOCK_OPTIONS = {};
1775
- /**
1776
- * Generates a GeoJSON FeatureCollection for a "Block" tactical symbol (T-shape).
1777
- *
1778
- * @param coordinates - An array of GeoJSON Positions (Lon/Lat).
1779
- * Expects exactly 3 points:
1780
- * - PT1 & PT2: Endpoints of the vertical line.
1781
- * - PT3: Defines the endpoint of the horizontal line (stem).
1782
- * @param options - Configuration options.
1783
- * @returns A GeoJSON FeatureCollection containing a LineString.
1784
- */
1785
- function createBlock(coordinates, _options = {}) {
1786
- const p1 = project(coordinates[0][0], coordinates[0][1]);
1787
- const p2 = project(coordinates[1][0], coordinates[1][1]);
1788
- const frame = createProjectedBaselineFrame(p1, p2, {
1789
- origin: "midpoint",
1790
- normal: "right"
1791
- });
1792
- if (!frame) return {
1793
- type: "FeatureCollection",
1794
- features: []
1795
- };
1796
- const c3 = coordinates[2];
1797
- const stemDistance = c3 ? frame.signedNormalDistance(c3) : frame.length * DEFAULT_STEM_RATIO$1;
1798
- const mid = frame.origin;
1799
- const pStemEnd = vecAdd(mid, vecScale(frame.normal, stemDistance));
1800
- return {
1801
- type: "FeatureCollection",
1802
- features: [{
1803
- type: "Feature",
1804
- properties: {},
1805
- geometry: {
1806
- type: "LineString",
1807
- coordinates: [
1808
- unproject(p1[0], p1[1]),
1809
- unproject(p2[0], p2[1]),
1810
- unproject(mid[0], mid[1]),
1811
- unproject(pStemEnd[0], pStemEnd[1])
1812
- ]
1813
- }
1814
- }]
1815
- };
1816
- }
1817
- const BLOCK = defineControlMeasure({
1818
- metadata: BLOCK_METADATA,
1819
- generator: createBlock,
1820
- defaultOptions: DEFAULT_BLOCK_OPTIONS,
1821
- rule: blockDrawRule
1822
- });
1823
- //#endregion
1824
- //#region src/internal/angle-utils.ts
1825
- /**
1826
- * Wraps an angle (radians) into the half-open range (-π, π].
1827
- */
1828
- function normalizeRadians(angle) {
1829
- let normalized = angle % (Math.PI * 2);
1830
- if (normalized > Math.PI) normalized -= Math.PI * 2;
1831
- if (normalized <= -Math.PI) normalized += Math.PI * 2;
1832
- return normalized;
1833
- }
1834
- /**
1835
- * Flips a text rotation by π when it would otherwise read upside-down, keeping
1836
- * the baseline pointing left-to-right (within ±90° of horizontal).
1837
- */
1838
- function keepTextLeftToRight(rotation) {
1839
- const normalized = normalizeRadians(rotation);
1840
- if (normalized > Math.PI / 2) return normalized - Math.PI;
1841
- if (normalized < -Math.PI / 2) return normalized + Math.PI;
1842
- return normalized;
1843
- }
1844
- //#endregion
1845
- //#region src/generators/cm34-mission-tasks/shared.ts
1846
- /**
1847
- * Builds an open chevron arrowhead (left → tip → right) as unprojected
1848
- * coordinates. The tip sits at `tip`, pointing along `dir`, with the base set
1849
- * back by `length` and spanning `width` perpendicular to the shaft. Mirrors the
1850
- * `createArrowHead` helper used by the attack-family generators.
1851
- */
1852
- function createArrowHead$1(tip, dir, length, width) {
1853
- const norm = [-dir[1], dir[0]];
1854
- const base = vecSub(tip, vecScale(dir, length));
1855
- const left = vecAdd(base, vecScale(norm, width / 2));
1856
- const right = vecSub(base, vecScale(norm, width / 2));
1857
- return [
1858
- unproject(left[0], left[1]),
1859
- unproject(tip[0], tip[1]),
1860
- unproject(right[0], right[1])
1861
- ];
1862
- }
1863
- /**
1864
- * Builds the glyph label as a Point feature, forwarding any captured label-size
1865
- * metadata to adapters for styling. `point` is in projected meters; the rotation
1866
- * must already be in the generator's feature-rotation convention. Shared by every
1867
- * mission-task measure that centers a glyph on its symbol.
1868
- */
1869
- function createLabelFeature(point, text, rotation, labelSize = {}) {
1870
- return {
1871
- type: "Feature",
1872
- properties: {
1873
- part: "label",
1874
- text,
1875
- rotation,
1876
- ...labelSize.pixels !== void 0 ? { textSizePixels: labelSize.pixels } : {},
1877
- ...labelSize.zoom !== void 0 ? { textSizeZoom: labelSize.zoom } : {},
1878
- ...labelSize.resolution !== void 0 ? { textSizeResolution: labelSize.resolution } : {}
1879
- },
1880
- geometry: {
1881
- type: "Point",
1882
- coordinates: unproject(point[0], point[1])
1883
- }
1884
- };
1885
- }
1886
- /**
1887
- * Shared scaffold for the "bracket" mission-task symbols (breach, bypass,
1888
- * canalize). Owns the three-sided bracket: a front opening (PT. 1 / PT. 2), a
1889
- * rear line parallel to it through PT. 3, and a glyph centered on the rear line.
1890
- * Each measure supplies only its end caps and label glyph via
1891
- * {@link BracketSymbolConfig}.
1892
- *
1893
- * Input Points:
1894
- * - PT. 1: Front opening endpoint (top/left)
1895
- * - PT. 2: Front opening endpoint (bottom/right) - defines height with PT. 1
1896
- * - PT. 3: Rear point - defines length and orientation
1897
- *
1898
- * Geometric Construction:
1899
- * 1. PT. 1 and PT. 2 define the front opening (facing enemy/objective)
1900
- * 2. A parallel line is projected through PT. 3 with identical height
1901
- * 3. LineString follows: PT. 1 → Rear Top → Rear Bottom → PT. 2
1902
- * 4. Measure-specific end caps at PT. 1 and PT. 2
1903
- * 5. Label point at midpoint of rear line (styled with OpenLayers Text)
1904
- *
1905
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
1906
- * @param config - Per-measure end caps, label glyph, and sizing
1907
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
1908
- */
1909
- function createBracketSymbol(coordinates, config) {
1910
- const [c1, c2, c3] = coordinates;
1911
- const p1 = project(c1[0], c1[1]);
1912
- const p2 = project(c2[0], c2[1]);
1913
- const p3 = project(c3[0], c3[1]);
1914
- const vFront = vecSub(p2, p1);
1915
- const frontLength = vecMag(vFront);
1916
- if (frontLength < 1e-6) return {
1917
- type: "FeatureCollection",
1918
- features: []
1919
- };
1920
- const dirFront = vecNorm(vFront);
1921
- const frontMidpoint = vecScale(vecAdd(p1, p2), .5);
1922
- const vToRear = vecSub(p3, frontMidpoint);
1923
- const perpDir = [-dirFront[1], dirFront[0]];
1924
- const perpDistance = vToRear[0] * perpDir[0] + vToRear[1] * perpDir[1];
1925
- const halfFront = vecScale(dirFront, frontLength / 2);
1926
- const rearMidpoint = vecAdd(frontMidpoint, vecScale(perpDir, perpDistance));
1927
- const rearTop = vecSub(rearMidpoint, halfFront);
1928
- const rearBottom = vecAdd(rearMidpoint, halfFront);
1929
- const gapHalf = frontLength * config.labelGapRatio / 2;
1930
- const gapPt1 = vecAdd(rearMidpoint, vecScale(dirFront, -gapHalf));
1931
- const gapPt2 = vecAdd(rearMidpoint, vecScale(dirFront, gapHalf));
1932
- const [topCap, bottomCap] = config.buildEndCaps({
1933
- p1,
1934
- p2,
1935
- dirFront,
1936
- perpDir,
1937
- frontLength,
1938
- rearTop
1939
- });
1940
- const mainFeature = {
1941
- type: "Feature",
1942
- properties: { part: config.part },
1943
- geometry: {
1944
- type: "MultiLineString",
1945
- coordinates: [
1946
- topCap,
1947
- [
1948
- unproject(p1[0], p1[1]),
1949
- unproject(rearTop[0], rearTop[1]),
1950
- unproject(gapPt1[0], gapPt1[1])
1951
- ],
1952
- [
1953
- unproject(gapPt2[0], gapPt2[1]),
1954
- unproject(rearBottom[0], rearBottom[1]),
1955
- unproject(p2[0], p2[1])
1956
- ],
1957
- bottomCap
1958
- ]
1959
- }
1960
- };
1961
- const rearLineRotation = Math.atan2(dirFront[1], dirFront[0]);
1962
- const renderedLabelRotation = Math.PI - (rearLineRotation - Math.PI / 2);
1963
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
1964
- return {
1965
- type: "FeatureCollection",
1966
- features: [mainFeature, createLabelFeature(rearMidpoint, config.labelText, labelRotation, config.labelSize)]
1967
- };
1968
- }
1969
- //#endregion
1970
- //#region src/generators/cm34-mission-tasks/block.ts
1971
- const DEFAULT_STEM_RATIO = 1.5;
1972
- const DEFAULT_BLOCK_MISSION_TASK_OPTIONS = { labelGapRatio: .2 };
1973
- const BLOCK_MISSION_TASK_METADATA = {
1974
- id: "block-mission-task",
1975
- name: "Block",
1976
- description: "A tactical mission task that denies the enemy access to an area or prevents enemy advance in a direction or along an avenue of approach.",
1977
- entity: "Mission Tasks",
1978
- entityType: "Block",
1979
- value: "340100",
1980
- minCoordinates: 2,
1981
- geometry: "line",
1982
- geometryTypes: ["MultiLineString", "Point"],
1983
- drawRule: "Area11",
1984
- capturesLabelSize: true,
1985
- params: [{
1986
- key: "labelGapRatio",
1987
- label: "Label gap",
1988
- description: "Size of the shaft gap holding the 'B' label, as a ratio of the front length.",
1989
- type: "number",
1990
- min: 0,
1991
- max: 1,
1992
- step: .01
1993
- }]
1994
- };
1995
- /**
1996
- * Generates a GeoJSON FeatureCollection for a BLOCK mission task symbol (340100).
1997
- *
1998
- * Uses the same T-shape geometry as the protection-area Block symbol, with a
1999
- * "B" label centered on the shaft.
2000
- *
2001
- * @param coordinates - [PT. 1, PT. 2, optional PT. 3] as Position arrays
2002
- * @param options - Configuration options
2003
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2004
- */
2005
- function createBlockMissionTaskSymbol(coordinates, options = {}) {
2006
- const labelGapRatio = options.labelGapRatio ?? DEFAULT_BLOCK_MISSION_TASK_OPTIONS.labelGapRatio;
2007
- const p1 = project(coordinates[0][0], coordinates[0][1]);
2008
- const p2 = project(coordinates[1][0], coordinates[1][1]);
2009
- const frame = createProjectedBaselineFrame(p1, p2, {
2010
- origin: "midpoint",
2011
- normal: "right"
2012
- });
2013
- if (!frame) return {
2014
- type: "FeatureCollection",
2015
- features: []
2016
- };
2017
- const c3 = coordinates[2];
2018
- const stemDistance = c3 ? frame.signedNormalDistance(c3) : frame.length * DEFAULT_STEM_RATIO;
2019
- const mid = frame.origin;
2020
- const pStemEnd = vecAdd(mid, vecScale(frame.normal, stemDistance));
2021
- const labelPoint = vecAdd(mid, vecScale(frame.normal, stemDistance / 2));
2022
- const stemDirection = vecScale(frame.normal, stemDistance < 0 ? -1 : 1);
2023
- const gapHalf = Math.min(frame.length * labelGapRatio / 2, Math.abs(stemDistance) / 2);
2024
- const gapStart = vecAdd(labelPoint, vecScale(stemDirection, -gapHalf));
2025
- const gapEnd = vecAdd(labelPoint, vecScale(stemDirection, gapHalf));
2026
- const shaftRotation = Math.atan2(frame.normal[1], frame.normal[0]);
2027
- const renderedLabelRotation = Math.PI - shaftRotation;
2028
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
2029
- return {
2030
- type: "FeatureCollection",
2031
- features: [{
2032
- type: "Feature",
2033
- properties: { part: "block" },
2034
- geometry: {
2035
- type: "MultiLineString",
2036
- coordinates: [
2037
- [unproject(p1[0], p1[1]), unproject(p2[0], p2[1])],
2038
- [unproject(mid[0], mid[1]), unproject(gapStart[0], gapStart[1])],
2039
- [unproject(gapEnd[0], gapEnd[1]), unproject(pStemEnd[0], pStemEnd[1])]
2040
- ]
2041
- }
2042
- }, createLabelFeature(labelPoint, "B", labelRotation, {
2043
- pixels: options.labelSizePixels,
2044
- zoom: options.labelSizeZoom,
2045
- resolution: options.labelSizeResolution
2046
- })]
2047
- };
2048
- }
2049
- const BLOCK_MISSION_TASK = defineControlMeasure({
2050
- metadata: BLOCK_MISSION_TASK_METADATA,
2051
- generator: createBlockMissionTaskSymbol,
2052
- defaultOptions: DEFAULT_BLOCK_MISSION_TASK_OPTIONS,
2053
- rule: blockDrawRule
2054
- });
2055
- //#endregion
2056
- //#region src/generators/cm34-mission-tasks/breach.ts
2057
- /**
2058
- * Default options for the BREACH symbol.
2059
- */
2060
- const DEFAULT_BREACH_OPTIONS = {
2061
- tickLengthRatio: .15,
2062
- tickAngle: 45,
2063
- labelGapRatio: .2
2064
- };
2065
- const BREACH_METADATA = {
2066
- id: "breach",
2067
- name: "Breach",
2068
- description: "A tactical mission task in which a unit breaks through or establishes a passage through an enemy obstacle.",
2069
- entity: "Mission Tasks",
2070
- entityType: "Breach",
2071
- value: "340200",
2072
- minCoordinates: 3,
2073
- maxCoordinates: 3,
2074
- geometry: "line",
2075
- geometryTypes: ["MultiLineString", "Point"],
2076
- drawRule: "Point12",
2077
- capturesLabelSize: true,
2078
- params: [
2079
- {
2080
- key: "tickLengthRatio",
2081
- label: "Tick length",
2082
- description: "Length of the outward end ticks, as a ratio of the front opening width.",
2083
- type: "number",
2084
- min: .01,
2085
- max: 1,
2086
- step: .01
2087
- },
2088
- {
2089
- key: "tickAngle",
2090
- label: "Tick angle",
2091
- description: "Outward lean of the end ticks from perpendicular, in degrees.",
2092
- type: "number",
2093
- min: 0,
2094
- max: 90,
2095
- step: 1
2096
- },
2097
- {
2098
- key: "labelGapRatio",
2099
- label: "Label gap",
2100
- description: "Size of the rear-line gap holding the 'B' label, as a ratio of the front length.",
2101
- type: "number",
2102
- min: 0,
2103
- max: 1,
2104
- step: .01
2105
- }
2106
- ]
2107
- };
2108
- /**
2109
- * Generates a GeoJSON FeatureCollection for a BREACH mission task symbol (340200).
2110
- *
2111
- * A three-sided bracket (see {@link createBracketSymbol}) whose front opening
2112
- * endpoints terminate in tick marks pointing **outward** (away from the rear
2113
- * "B" line).
2114
- *
2115
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
2116
- * @param options - Configuration options
2117
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2118
- */
2119
- function createBreachSymbol(coordinates, options = {}) {
2120
- const tickLengthRatio = options.tickLengthRatio ?? DEFAULT_BREACH_OPTIONS.tickLengthRatio;
2121
- const tickAngle = options.tickAngle ?? DEFAULT_BREACH_OPTIONS.tickAngle;
2122
- return createBracketSymbol(coordinates, {
2123
- part: "breach",
2124
- labelText: "B",
2125
- labelGapRatio: options.labelGapRatio ?? DEFAULT_BREACH_OPTIONS.labelGapRatio,
2126
- labelSize: {
2127
- pixels: options.labelSizePixels,
2128
- zoom: options.labelSizeZoom,
2129
- resolution: options.labelSizeResolution
2130
- },
2131
- buildEndCaps: ({ p1, p2, dirFront, perpDir, frontLength }) => {
2132
- const halfTick = frontLength * tickLengthRatio / 2;
2133
- const tickAngleRad = tickAngle * Math.PI / 180;
2134
- const cosTick = Math.cos(tickAngleRad);
2135
- const sinTick = Math.sin(tickAngleRad);
2136
- const outwardDir = [-perpDir[0], -perpDir[1]];
2137
- const tick1Dir = vecAdd(vecScale(outwardDir, cosTick), vecScale(dirFront, -sinTick));
2138
- const tick1Start = vecAdd(p1, vecScale(tick1Dir, -halfTick));
2139
- const tick1End = vecAdd(p1, vecScale(tick1Dir, halfTick));
2140
- const tick2Dir = vecAdd(vecScale(outwardDir, cosTick), vecScale(dirFront, sinTick));
2141
- const tick2Start = vecAdd(p2, vecScale(tick2Dir, -halfTick));
2142
- const tick2End = vecAdd(p2, vecScale(tick2Dir, halfTick));
2143
- return [[unproject(tick1Start[0], tick1Start[1]), unproject(tick1End[0], tick1End[1])], [unproject(tick2Start[0], tick2Start[1]), unproject(tick2End[0], tick2End[1])]];
2144
- }
2145
- });
2146
- }
2147
- const BREACH = defineControlMeasure({
2148
- metadata: BREACH_METADATA,
2149
- generator: createBreachSymbol,
2150
- defaultOptions: DEFAULT_BREACH_OPTIONS,
2151
- rule: point12DrawRule
2152
- });
2153
- //#endregion
2154
- //#region src/generators/cm34-mission-tasks/bypass.ts
2155
- /**
2156
- * Default options for the BYPASS symbol.
2157
- */
2158
- const DEFAULT_BYPASS_OPTIONS = {
2159
- arrowHeadLengthRatio: .12,
2160
- labelGapRatio: .2
2161
- };
2162
- const BYPASS_METADATA = {
2163
- id: "bypass",
2164
- name: "Bypass",
2165
- description: "A tactical mission task in which a unit maneuvers around an obstacle, position, or enemy force to maintain the momentum of the operation.",
2166
- entity: "Mission Tasks",
2167
- entityType: "Bypass",
2168
- value: "340300",
2169
- minCoordinates: 3,
2170
- maxCoordinates: 3,
2171
- geometry: "line",
2172
- geometryTypes: ["MultiLineString", "Point"],
2173
- drawRule: "Point12",
2174
- capturesLabelSize: true,
2175
- params: [{
2176
- key: "arrowHeadLengthRatio",
2177
- label: "Arrowhead length",
2178
- description: "Length of the outward end chevrons, as a ratio of the front opening width.",
2179
- type: "number",
2180
- min: .01,
2181
- max: 1,
2182
- step: .01
2183
- }, {
2184
- key: "labelGapRatio",
2185
- label: "Label gap",
2186
- description: "Size of the rear-line gap holding the 'B' label, as a ratio of the front length.",
2187
- type: "number",
2188
- min: 0,
2189
- max: 1,
2190
- step: .01
2191
- }]
2192
- };
2193
- /**
2194
- * Generates a GeoJSON FeatureCollection for a BYPASS mission task symbol (340300).
2195
- *
2196
- * A three-sided bracket (see {@link createBracketSymbol}) with a "B" label on
2197
- * the rear line, whose front opening endpoints terminate in outward-flaring
2198
- * chevron arrowheads instead of tick marks.
2199
- *
2200
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
2201
- * @param options - Configuration options
2202
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2203
- */
2204
- function createBypassSymbol(coordinates, options = {}) {
2205
- const arrowHeadLengthRatio = options.arrowHeadLengthRatio ?? DEFAULT_BYPASS_OPTIONS.arrowHeadLengthRatio;
2206
- return createBracketSymbol(coordinates, {
2207
- part: "bypass",
2208
- labelText: "B",
2209
- labelGapRatio: options.labelGapRatio ?? DEFAULT_BYPASS_OPTIONS.labelGapRatio,
2210
- labelSize: {
2211
- pixels: options.labelSizePixels,
2212
- zoom: options.labelSizeZoom,
2213
- resolution: options.labelSizeResolution
2214
- },
2215
- buildEndCaps: ({ p1, p2, frontLength, rearTop }) => {
2216
- const arrowHeadLength = frontLength * arrowHeadLengthRatio;
2217
- const arrowDir = vecNorm(vecSub(p1, rearTop));
2218
- return [createArrowHead$1(p1, arrowDir, arrowHeadLength, arrowHeadLength), createArrowHead$1(p2, arrowDir, arrowHeadLength, arrowHeadLength)];
2219
- }
2220
- });
2221
- }
2222
- const BYPASS = defineControlMeasure({
2223
- metadata: BYPASS_METADATA,
2224
- generator: createBypassSymbol,
2225
- defaultOptions: DEFAULT_BYPASS_OPTIONS,
2226
- rule: point12DrawRule
2227
- });
2228
- //#endregion
2229
- //#region src/generators/cm34-mission-tasks/canalize.ts
2230
- /**
2231
- * Default options for the CANALIZE symbol.
2232
- */
2233
- const DEFAULT_CANALIZE_OPTIONS = {
2234
- tickLengthRatio: .15,
2235
- tickAngle: 45,
2236
- labelGapRatio: .2
2237
- };
2238
- const CANALIZE_METADATA = {
2239
- id: "canalize",
2240
- name: "Canalize",
2241
- description: "Canalize is a tactical mission task in which a unit restricts enemy movement to a narrow zone",
2242
- entity: "Mission Tasks",
2243
- entityType: "Canalize",
2244
- value: "340400",
2245
- minCoordinates: 3,
2246
- maxCoordinates: 3,
2247
- geometry: "line",
2248
- geometryTypes: ["MultiLineString", "Point"],
2249
- drawRule: "Point12",
2250
- capturesLabelSize: true,
2251
- params: [
2252
- {
2253
- key: "tickLengthRatio",
2254
- label: "Tick length",
2255
- description: "Length of the inward end ticks, as a ratio of the front opening width.",
2256
- type: "number",
2257
- min: .01,
2258
- max: 1,
2259
- step: .01
2260
- },
2261
- {
2262
- key: "tickAngle",
2263
- label: "Tick angle",
2264
- description: "Inward lean of the end ticks from perpendicular, in degrees.",
2265
- type: "number",
2266
- min: 0,
2267
- max: 90,
2268
- step: 1
2269
- },
2270
- {
2271
- key: "labelGapRatio",
2272
- label: "Label gap",
2273
- description: "Size of the rear-line gap holding the 'C' label, as a ratio of the front length.",
2274
- type: "number",
2275
- min: 0,
2276
- max: 1,
2277
- step: .01
2278
- }
2279
- ]
2280
- };
2281
- /**
2282
- * Generates a GeoJSON FeatureCollection for a CANALIZE mission task symbol (340400).
2283
- *
2284
- * A three-sided bracket (see {@link createBracketSymbol}) whose front opening
2285
- * endpoints terminate in tick marks pointing **inward** (toward the rear "C"
2286
- * line), opposite BREACH's outward-facing ticks.
2287
- *
2288
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
2289
- * @param options - Configuration options
2290
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2291
- */
2292
- function createCanalizeSymbol(coordinates, options = {}) {
2293
- const tickLengthRatio = options.tickLengthRatio ?? DEFAULT_CANALIZE_OPTIONS.tickLengthRatio;
2294
- const tickAngle = options.tickAngle ?? DEFAULT_CANALIZE_OPTIONS.tickAngle;
2295
- return createBracketSymbol(coordinates, {
2296
- part: "canalize",
2297
- labelText: "C",
2298
- labelGapRatio: options.labelGapRatio ?? DEFAULT_CANALIZE_OPTIONS.labelGapRatio,
2299
- labelSize: {
2300
- pixels: options.labelSizePixels,
2301
- zoom: options.labelSizeZoom,
2302
- resolution: options.labelSizeResolution
2303
- },
2304
- buildEndCaps: ({ p1, p2, dirFront, perpDir, frontLength }) => {
2305
- const halfTick = frontLength * tickLengthRatio / 2;
2306
- const tickAngleRad = tickAngle * Math.PI / 180;
2307
- const cosTick = Math.cos(tickAngleRad);
2308
- const sinTick = Math.sin(tickAngleRad);
2309
- const tick1Dir = vecAdd(vecScale(perpDir, cosTick), vecScale(dirFront, -sinTick));
2310
- const tick1Start = vecAdd(p1, vecScale(tick1Dir, -halfTick));
2311
- const tick1End = vecAdd(p1, vecScale(tick1Dir, halfTick));
2312
- const tick2Dir = vecAdd(vecScale(perpDir, cosTick), vecScale(dirFront, sinTick));
2313
- const tick2Start = vecAdd(p2, vecScale(tick2Dir, -halfTick));
2314
- const tick2End = vecAdd(p2, vecScale(tick2Dir, halfTick));
2315
- return [[unproject(tick1Start[0], tick1Start[1]), unproject(tick1End[0], tick1End[1])], [unproject(tick2Start[0], tick2Start[1]), unproject(tick2End[0], tick2End[1])]];
2316
- }
2317
- });
2318
- }
2319
- const CANALIZE = defineControlMeasure({
2320
- metadata: CANALIZE_METADATA,
2321
- generator: createCanalizeSymbol,
2322
- defaultOptions: DEFAULT_CANALIZE_OPTIONS,
2323
- rule: point12DrawRule
2324
- });
2325
- //#endregion
2326
- //#region src/generators/cm34-mission-tasks/clear.ts
2327
- /**
2328
- * Default options for the CLEAR symbol.
2329
- */
2330
- const DEFAULT_CLEAR_OPTIONS = {
2331
- arrowInsetRatio: .15,
2332
- arrowheadLengthRatio: .15,
2333
- arrowheadWidthRatio: .2,
2334
- labelGapRatio: .2
2335
- };
2336
- const CLEAR_METADATA = {
2337
- id: "clear",
2338
- name: "Clear",
2339
- description: "Clear is a tactical mission task that requires a unit to remove all enemy forces and eliminate organized resistance within an assigned area.",
2340
- entity: "Mission Tasks",
2341
- entityType: "Clear",
2342
- value: "340500",
2343
- minCoordinates: 3,
2344
- maxCoordinates: 3,
2345
- geometry: "line",
2346
- geometryTypes: ["MultiLineString", "Point"],
2347
- drawRule: "Line23",
2348
- capturesLabelSize: true,
2349
- params: [
2350
- {
2351
- key: "arrowInsetRatio",
2352
- label: "Arrow padding",
2353
- description: "Padding between the line endpoints and the outer arrows, as a ratio of height.",
2354
- type: "number",
2355
- min: 0,
2356
- max: .45,
2357
- step: .01
2358
- },
2359
- {
2360
- key: "arrowheadLengthRatio",
2361
- label: "Arrowhead length",
2362
- description: "Depth of each arrowhead toward the rear, as a ratio of the symbol height.",
2363
- type: "number",
2364
- min: .01,
2365
- max: 1,
2366
- step: .01
2367
- },
2368
- {
2369
- key: "arrowheadWidthRatio",
2370
- label: "Arrowhead width",
2371
- description: "Width of each arrowhead along the vertical line, as a ratio of the height.",
2372
- type: "number",
2373
- min: .01,
2374
- max: 1,
2375
- step: .01
2376
- },
2377
- {
2378
- key: "labelGapRatio",
2379
- label: "Label gap",
2380
- description: "Size of the shaft gap holding the 'C' label, as a ratio of the shaft length.",
2381
- type: "number",
2382
- min: 0,
2383
- max: 1,
2384
- step: .01
2385
- }
2386
- ]
2387
- };
2388
- /**
2389
- * Generates a GeoJSON FeatureCollection for a CLEAR mission task symbol (340500).
2390
- *
2391
- * A vertical line (PT. 1 → PT. 2) with three arrows pointing at it,
2392
- * perpendicular to the line. The arrows come from the rear — the side PT. 3
2393
- * sits on — and their tips touch the vertical line; each carries a shaft
2394
- * reaching back to the rear. The arrows are evenly spaced along the line, so
2395
- * the middle arrow's tip sits at the midpoint of the vertical line and its
2396
- * shaft carries the 'C' glyph. The arrows stay perpendicular to the vertical
2397
- * line at any rotation.
2398
- *
2399
- * Input Points:
2400
- * - PT. 1: Top endpoint of the vertical line
2401
- * - PT. 2: Bottom endpoint of the vertical line — defines height with PT. 1
2402
- * - PT. 3: Rear point — defines the shaft length (and which side the arrows
2403
- * come from)
2404
- *
2405
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
2406
- * @param options - Configuration options
2407
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2408
- */
2409
- function createClearSymbol(coordinates, options = {}) {
2410
- const arrowInsetRatio = Math.min(.49, Math.max(0, options.arrowInsetRatio ?? DEFAULT_CLEAR_OPTIONS.arrowInsetRatio));
2411
- const arrowheadLengthRatio = options.arrowheadLengthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadLengthRatio;
2412
- const arrowheadWidthRatio = options.arrowheadWidthRatio ?? DEFAULT_CLEAR_OPTIONS.arrowheadWidthRatio;
2413
- const labelGapRatio = options.labelGapRatio ?? DEFAULT_CLEAR_OPTIONS.labelGapRatio;
2414
- const [c1, c2, c3] = coordinates;
2415
- const p1 = project(c1[0], c1[1]);
2416
- const p2 = project(c2[0], c2[1]);
2417
- const p3 = project(c3[0], c3[1]);
2418
- const vLine = vecSub(p2, p1);
2419
- const height = vecMag(vLine);
2420
- if (height < 1e-6) return {
2421
- type: "FeatureCollection",
2422
- features: []
2423
- };
2424
- const dirLine = vecScale(vLine, 1 / height);
2425
- const midLine = vecScale(vecAdd(p1, p2), .5);
2426
- const rawPerp = [-dirLine[1], dirLine[0]];
2427
- const perpToRear = vecScale(rawPerp, vecDot(vecSub(p3, midLine), rawPerp) < 0 ? -1 : 1);
2428
- const shaftLength = Math.abs(vecDot(vecSub(p3, midLine), rawPerp));
2429
- const arrowDir = vecScale(perpToRear, -1);
2430
- const arrowheadLength = height * arrowheadLengthRatio;
2431
- const arrowheadWidth = height * arrowheadWidthRatio;
2432
- const tipAt = (t) => vecAdd(p1, vecScale(dirLine, t * height));
2433
- const lines = [[unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]];
2434
- const fullTs = [
2435
- arrowInsetRatio,
2436
- .5,
2437
- 1 - arrowInsetRatio
2438
- ];
2439
- const labelIndex = 1;
2440
- fullTs.forEach((t, i) => {
2441
- const tip = tipAt(t);
2442
- const rearEnd = vecAdd(tip, vecScale(perpToRear, shaftLength));
2443
- if (i === labelIndex && shaftLength > 1e-6) {
2444
- const labelCenter = vecAdd(tip, vecScale(perpToRear, shaftLength * .5));
2445
- const gapHalf = shaftLength * labelGapRatio / 2;
2446
- const gapRear = vecAdd(labelCenter, vecScale(perpToRear, gapHalf));
2447
- const gapTip = vecAdd(labelCenter, vecScale(perpToRear, -gapHalf));
2448
- lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
2449
- lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
2450
- } else if (shaftLength > 1e-6) lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(tip[0], tip[1])]);
2451
- lines.push(createArrowHead$1(tip, arrowDir, arrowheadLength, arrowheadWidth));
2452
- });
2453
- const mainFeature = {
2454
- type: "Feature",
2455
- properties: { part: "clear" },
2456
- geometry: {
2457
- type: "MultiLineString",
2458
- coordinates: lines
2459
- }
2460
- };
2461
- const lineRotation = Math.atan2(dirLine[1], dirLine[0]);
2462
- const renderedLabelRotation = Math.PI - (lineRotation - Math.PI / 2);
2463
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
2464
- const labelTip = tipAt(fullTs[labelIndex]);
2465
- return {
2466
- type: "FeatureCollection",
2467
- features: [mainFeature, createLabelFeature(shaftLength > 1e-6 ? vecAdd(labelTip, vecScale(perpToRear, shaftLength * .5)) : labelTip, "C", labelRotation, {
2468
- pixels: options.labelSizePixels,
2469
- zoom: options.labelSizeZoom,
2470
- resolution: options.labelSizeResolution
2471
- })]
2472
- };
2473
- }
2474
- const CLEAR = defineControlMeasure({
2475
- metadata: CLEAR_METADATA,
2476
- generator: createClearSymbol,
2477
- defaultOptions: DEFAULT_CLEAR_OPTIONS,
2478
- rule: line23DrawRule
2479
- });
2480
- //#endregion
2481
- //#region src/generators/cm34-mission-tasks/delay.ts
2482
- const DEFAULT_DELAY_OPTIONS = {
2483
- arrowheadLengthRatio: .12,
2484
- arrowheadWidthRatio: .12,
2485
- labelGapRatio: .18,
2486
- arcSegments: 32
2487
- };
2488
- const DELAY_METADATA = {
2489
- id: "delay",
2490
- name: "Delay",
2491
- description: "Delay is a tactical mission task in which a force under pressure trades space for time by slowing enemy momentum and inflicting maximum damage without becoming decisively engaged.",
2492
- entity: "Mission Tasks",
2493
- entityType: "Delay",
2494
- value: "340800",
2495
- minCoordinates: 3,
2496
- maxCoordinates: 3,
2497
- geometry: "line",
2498
- geometryTypes: ["MultiLineString", "Point"],
2499
- drawRule: "Line24",
2500
- capturesLabelSize: true,
2501
- params: [
2502
- {
2503
- key: "arrowheadLengthRatio",
2504
- label: "Arrowhead length",
2505
- description: "Depth of the arrowhead, as a ratio of the straight-line length.",
2506
- type: "number",
2507
- min: .01,
2508
- max: 1,
2509
- step: .01
2510
- },
2511
- {
2512
- key: "arrowheadWidthRatio",
2513
- label: "Arrowhead width",
2514
- description: "Width of the arrowhead, as a ratio of the straight-line length.",
2515
- type: "number",
2516
- min: .01,
2517
- max: 1,
2518
- step: .01
2519
- },
2520
- {
2521
- key: "labelGapRatio",
2522
- label: "Label gap",
2523
- description: "Size of the shaft gap holding the 'D' label, as a ratio of the shaft length.",
2524
- type: "number",
2525
- min: 0,
2526
- max: 1,
2527
- step: .01
2528
- },
2529
- {
2530
- key: "arcSegments",
2531
- label: "Arc segments",
2532
- description: "Number of straight segments used to approximate the semicircular arc.",
2533
- type: "number",
2534
- min: 4,
2535
- max: 96,
2536
- step: 1
2537
- }
2538
- ]
2539
- };
2540
- function createDelaySymbol(coordinates, options = {}) {
2541
- const [c1, c2, c3] = coordinates;
2542
- const p1 = project(c1[0], c1[1]);
2543
- const p2 = project(c2[0], c2[1]);
2544
- const p3 = project(c3[0], c3[1]);
2545
- const line = vecSub(p2, p1);
2546
- const lineLength = vecMag(line);
2547
- if (lineLength < 1e-6) return {
2548
- type: "FeatureCollection",
2549
- features: []
2550
- };
2551
- const dirLine = vecScale(line, 1 / lineLength);
2552
- const diameterAxis = [dirLine[1], -dirLine[0]];
2553
- const signedDiameter = vecDot(vecSub(p3, p2), diameterAxis);
2554
- const diameterEnd = vecAdd(p2, vecScale(diameterAxis, signedDiameter));
2555
- const diameter = Math.abs(signedDiameter);
2556
- const gapHalf = lineLength * Math.min(1, Math.max(0, options.labelGapRatio ?? DEFAULT_DELAY_OPTIONS.labelGapRatio)) / 2;
2557
- const labelPoint = vecAdd(p1, vecScale(dirLine, lineLength / 2));
2558
- const gapStart = vecAdd(labelPoint, vecScale(dirLine, -gapHalf));
2559
- const gapEnd = vecAdd(labelPoint, vecScale(dirLine, gapHalf));
2560
- const lines = [
2561
- [unproject(p1[0], p1[1]), unproject(gapStart[0], gapStart[1])],
2562
- [unproject(gapEnd[0], gapEnd[1]), unproject(p2[0], p2[1])],
2563
- createArrowHead$1(p1, vecScale(dirLine, -1), lineLength * (options.arrowheadLengthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadLengthRatio), lineLength * (options.arrowheadWidthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadWidthRatio))
2564
- ];
2565
- if (diameter >= 1e-6) lines.push(createSemicircleArc(p2, diameterEnd, dirLine, options.arcSegments ?? DEFAULT_DELAY_OPTIONS.arcSegments));
2566
- const lineFeature = {
2567
- type: "Feature",
2568
- properties: { part: "delay" },
2569
- geometry: {
2570
- type: "MultiLineString",
2571
- coordinates: lines
2572
- }
2573
- };
2574
- const lineRotation = Math.atan2(dirLine[1], dirLine[0]);
2575
- return {
2576
- type: "FeatureCollection",
2577
- features: [lineFeature, createLabelFeature(labelPoint, "D", normalizeRadians(Math.PI + lineRotation), {
2578
- pixels: options.labelSizePixels,
2579
- zoom: options.labelSizeZoom,
2580
- resolution: options.labelSizeResolution
2581
- })]
2582
- };
2583
- }
2584
- function createSemicircleArc(start, end, bulgeDir, requestedSegments) {
2585
- const diameterVector = vecSub(end, start);
2586
- const diameter = vecMag(diameterVector);
2587
- if (diameter < 1e-6) return [unproject(start[0], start[1])];
2588
- const diameterDir = vecNorm(diameterVector);
2589
- const center = vecAdd(start, vecScale(diameterVector, .5));
2590
- const radius = diameter / 2;
2591
- const segments = Math.max(4, Math.round(requestedSegments));
2592
- const points = [];
2593
- for (let i = 0; i <= segments; i += 1) {
2594
- const theta = i / segments * Math.PI;
2595
- const point = vecAdd(center, vecAdd(vecScale(diameterDir, -Math.cos(theta) * radius), vecScale(bulgeDir, Math.sin(theta) * radius)));
2596
- points.push(unproject(point[0], point[1]));
2597
- }
2598
- return points;
2599
- }
2600
- const DELAY = defineControlMeasure({
2601
- metadata: DELAY_METADATA,
2602
- generator: createDelaySymbol,
2603
- defaultOptions: DEFAULT_DELAY_OPTIONS,
2604
- rule: line24DrawRule
2605
- });
2606
- //#endregion
2607
- //#region src/internal/arrowhead.ts
2608
- /**
2609
- * Builds a closed, filled arrowhead triangle ring in unprojected coordinates.
2610
- *
2611
- * `tip` is the point of the arrow, `dir` the unit direction the arrow points,
2612
- * `length` the distance from tip back to the base, and `width` the base width.
2613
- * The returned ring is `[left, tip, right, left]`.
2614
- */
2615
- function createArrowHeadRing(tip, dir, length, width) {
2616
- const perp = [-dir[1], dir[0]];
2617
- const base = vecSub(tip, vecScale(dir, length));
2618
- const left = vecAdd(base, vecScale(perp, width / 2));
2619
- const right = vecSub(base, vecScale(perp, width / 2));
2620
- return [
2621
- unproject(left[0], left[1]),
2622
- unproject(tip[0], tip[1]),
2623
- unproject(right[0], right[1]),
2624
- unproject(left[0], left[1])
2625
- ];
2626
- }
2627
- //#endregion
2628
- //#region src/generators/cm27-protection-areas/disrupt.ts
2629
- const DEFAULT_DISRUPT_OPTIONS = {
2630
- middleArrowLengthRatio: .65,
2631
- bottomArrowLengthRatio: .45,
2632
- shaftLengthRatio: .65,
2633
- arrowHeadLengthRatio: .2,
2634
- arrowHeadWidthRatio: .2
2635
- };
2636
- const DISRUPT_METADATA = {
2637
- id: "disrupt",
2638
- name: "Disrupt",
2639
- description: "Obstacle effect that disrupts enemy tempo, delays movement, forces early breaching, and fragments attacks.",
2640
- entity: "Protection Areas",
2641
- entityType: "Obstacle Effects",
2642
- entitySubtype: "Disrupt",
2643
- value: "270502",
2644
- minCoordinates: 2,
2645
- geometry: "line",
2646
- geometryTypes: ["MultiLineString", "MultiPolygon"],
2647
- drawRule: "Area12",
2648
- params: [
2649
- {
2650
- key: "middleArrowLengthRatio",
2651
- label: "Middle arrow length",
2652
- description: "Middle arrow length as a ratio of the longest (top) arrow.",
2653
- type: "number",
2654
- min: .01,
2655
- max: 1,
2656
- step: .01
2657
- },
2658
- {
2659
- key: "bottomArrowLengthRatio",
2660
- label: "Bottom arrow length",
2661
- description: "Bottom arrow length as a ratio of the longest (top) arrow.",
2662
- type: "number",
2663
- min: .01,
2664
- max: 1,
2665
- step: .01
2666
- },
2667
- {
2668
- key: "shaftLengthRatio",
2669
- label: "Shaft length",
2670
- description: "Center shaft length as a ratio of the longest arrow, extending opposite.",
2671
- type: "number",
2672
- min: .01,
2673
- max: 1,
2674
- step: .01
2675
- },
2676
- {
2677
- key: "arrowHeadLengthRatio",
2678
- label: "Arrowhead length",
2679
- description: "Arrowhead length as a ratio of the longest arrow.",
2680
- type: "number",
2681
- min: .01,
2682
- max: 1,
2683
- step: .01
2684
- },
2685
- {
2686
- key: "arrowHeadWidthRatio",
2687
- label: "Arrowhead width",
2688
- description: "Arrowhead base width as a ratio of the longest arrow.",
2689
- type: "number",
2690
- min: .01,
2691
- max: 1,
2692
- step: .01
2693
- }
2694
- ]
2695
- };
2696
- /**
2697
- * Generates a GeoJSON FeatureCollection for a DISRUPT tactical symbol.
2698
- *
2699
- * Input:
2700
- * - PT1: Top endpoint of the vertical spine
2701
- * - PT2: Bottom endpoint of the vertical spine
2702
- * - PT3: Reference point used to determine the longest-arrow offset
2703
- *
2704
- * Geometry:
2705
- * - Spine: PT1 -> PT2
2706
- * - PT2 -> PT1 defines the baseline orientation; arrow direction is constrained to its perpendicular axis
2707
- * - PT3 is decomposed and projected onto the perpendicular axis through PT1 to derive a valid longest-arrow tip
2708
- * - Three arrows placed at PT1, midpoint(PT1, PT2), and PT2
2709
- * - Center shaft extends opposite arrow direction from midpoint with the same length as the middle arrow
2710
- * - Top arrow is longest
2711
- * - Middle and bottom arrows are proportional to the longest arrow
2712
- * - Arrow heads are returned as filled triangle polygons
2713
- */
2714
- function createDisrupt(coordinates, options = {}) {
2715
- const c1 = coordinates[0];
2716
- const c2 = coordinates[1];
2717
- const c3 = coordinates[2];
2718
- const middleArrowLengthRatio = options.middleArrowLengthRatio ?? DEFAULT_DISRUPT_OPTIONS.middleArrowLengthRatio;
2719
- const bottomArrowLengthRatio = options.bottomArrowLengthRatio ?? DEFAULT_DISRUPT_OPTIONS.bottomArrowLengthRatio;
2720
- const shaftLengthRatio = options.shaftLengthRatio ?? DEFAULT_DISRUPT_OPTIONS.shaftLengthRatio;
2721
- const arrowHeadLengthRatio = options.arrowHeadLengthRatio ?? DEFAULT_DISRUPT_OPTIONS.arrowHeadLengthRatio;
2722
- const arrowHeadWidthRatio = options.arrowHeadWidthRatio ?? DEFAULT_DISRUPT_OPTIONS.arrowHeadWidthRatio;
2723
- const p1 = project(c1[0], c1[1]);
2724
- const p2 = project(c2[0], c2[1]);
2725
- const v12 = vecSub(p2, p1);
2726
- const spineLength = vecMag(v12);
2727
- if (spineLength < 1e-6) return {
2728
- type: "FeatureCollection",
2729
- features: []
2730
- };
2731
- const spineDir = vecNorm(v12);
2732
- const normal = [spineDir[1], -spineDir[0]];
2733
- const spineMid = vecScale(vecAdd(p1, p2), .5);
2734
- let topArrowTip;
2735
- let longestArrowLength;
2736
- let arrowSideSign;
2737
- if (c3) {
2738
- const normalFromP1Raw = vecDot(vecSub(project(c3[0], c3[1]), p1), normal);
2739
- const hintedSign = normalFromP1Raw >= 0 ? 1 : -1;
2740
- let normalFromP1 = Number.isFinite(normalFromP1Raw) ? normalFromP1Raw : 0;
2741
- if (Math.abs(normalFromP1) < 1e-6) normalFromP1 = hintedSign * spineLength * 1.5;
2742
- topArrowTip = vecAdd(p1, vecScale(normal, normalFromP1));
2743
- const topVec = vecSub(topArrowTip, p1);
2744
- longestArrowLength = vecMag(topVec);
2745
- arrowSideSign = vecDot(topVec, normal) >= 0 ? 1 : -1;
2746
- } else {
2747
- longestArrowLength = spineLength * 1.5;
2748
- arrowSideSign = -1;
2749
- topArrowTip = vecAdd(p1, vecScale(normal, arrowSideSign * longestArrowLength));
2750
- }
2751
- if (longestArrowLength < 1e-6) return {
2752
- type: "FeatureCollection",
2753
- features: []
2754
- };
2755
- const arrowStarts = [
2756
- p1,
2757
- spineMid,
2758
- p2
2759
- ];
2760
- const arrowLengthRatios = [
2761
- 1,
2762
- middleArrowLengthRatio,
2763
- bottomArrowLengthRatio
2764
- ];
2765
- const lines = [[unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]];
2766
- const polygons = [];
2767
- const commonHeadLength = Math.max(longestArrowLength * arrowHeadLengthRatio, EPSILON);
2768
- const commonHeadWidth = Math.max(longestArrowLength * arrowHeadWidthRatio, EPSILON);
2769
- for (let i = 0; i < arrowStarts.length; i++) {
2770
- const start = arrowStarts[i];
2771
- const ratio = arrowLengthRatios[i];
2772
- const signedLength = arrowSideSign * longestArrowLength * ratio;
2773
- if (Math.abs(signedLength) < 1e-6) continue;
2774
- const tip = i === 0 ? topArrowTip : vecAdd(start, vecScale(normal, signedLength));
2775
- const dir = vecNorm(vecSub(tip, start));
2776
- const headBase = vecSub(tip, vecScale(dir, commonHeadLength));
2777
- lines.push([unproject(start[0], start[1]), unproject(headBase[0], headBase[1])]);
2778
- polygons.push([createArrowHeadRing(tip, dir, commonHeadLength, commonHeadWidth)]);
2779
- }
2780
- const shaftLengthSigned = arrowSideSign * longestArrowLength * shaftLengthRatio;
2781
- if (Math.abs(shaftLengthSigned) > 1e-6) {
2782
- const shaftStart = vecSub(spineMid, vecScale(normal, shaftLengthSigned));
2783
- lines.push([unproject(shaftStart[0], shaftStart[1]), unproject(spineMid[0], spineMid[1])]);
2784
- }
2785
- return {
2786
- type: "FeatureCollection",
2787
- features: [{
2788
- type: "Feature",
2789
- properties: {},
2790
- geometry: {
2791
- type: "MultiLineString",
2792
- coordinates: lines
2793
- }
2794
- }, {
2795
- type: "Feature",
2796
- properties: { fill: true },
2797
- geometry: {
2798
- type: "MultiPolygon",
2799
- coordinates: polygons
2800
- }
2801
- }]
2802
- };
2803
- }
2804
- const DISRUPT = defineControlMeasure({
2805
- metadata: DISRUPT_METADATA,
2806
- generator: createDisrupt,
2807
- defaultOptions: DEFAULT_DISRUPT_OPTIONS,
2808
- rule: disruptDrawRule
2809
- });
2810
- //#endregion
2811
- //#region src/internal/field-of-fire.ts
2812
- /**
2813
- * Builds the open (stroked) arrowhead at the outward tip of one arm. The two
2814
- * barbs are sized relative to that arm's own length, so each arm's head scales
2815
- * independently (Line3 — arms vary independently). Returns `null` when the arm
2816
- * has collapsed to zero length.
2817
- */
2818
- function arrowheadAt(vertex, tip, hSize, hWidth) {
2819
- const v = vecSub(tip, vertex);
2820
- const armLength = vecMag(v);
2821
- if (armLength < 1e-6) return null;
2822
- const dir = vecNorm(v);
2823
- const perp = [-dir[1], dir[0]];
2824
- const headLength = armLength * hSize;
2825
- const halfWidth = headLength * hWidth;
2826
- const base = vecSub(tip, vecScale(dir, headLength));
2827
- return [
2828
- vecAdd(base, vecScale(perp, halfWidth)),
2829
- tip,
2830
- vecSub(base, vecScale(perp, halfWidth))
2831
- ];
2832
- }
2833
- /**
2834
- * Projects the vertex (PT 1) and up to two tips and builds the V of arms, each
2835
- * arm ending in an open arrowhead. Renders one arm per tip, so two points
2836
- * (vertex + one tip) draws the single arm being placed — the live feedback
2837
- * after PT 1 — and three points draws the full V. Points beyond the third are
2838
- * ignored (input contract, ADR-0014).
2839
- *
2840
- * Returns `null` if any arm is degenerate (collapsed to zero length), the
2841
- * silent ADR-0014 empty-render signal.
2842
- */
2843
- function buildFieldOfFireV(coordinates, hSize, hWidth) {
2844
- const vertexCoord = coordinates[0];
2845
- const vertex = project(vertexCoord[0], vertexCoord[1]);
2846
- const tips = coordinates.slice(1, 3).map((c) => project(c[0], c[1]));
2847
- const lines = [];
2848
- for (const tip of tips) {
2849
- const head = arrowheadAt(vertex, tip, hSize, hWidth);
2850
- if (!head) return null;
2851
- lines.push([vertex, tip], head);
2852
- }
2853
- return {
2854
- vertex,
2855
- tips,
2856
- lines
2857
- };
2858
- }
2859
- //#endregion
2860
- //#region src/generators/cm14-maneuver-lines/finalProtectiveFire.ts
2861
- const BLADE_LENGTH_FRACTION = .8;
2862
- const DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS = {
2863
- hSize: .07,
2864
- hWidth: .67,
2865
- bladeHeight: .03
2866
- };
2867
- const FPF_SHARED = {
2868
- entity: "Maneuver Lines",
2869
- entityType: "Field of Fire",
2870
- minCoordinates: 2,
2871
- maxCoordinates: 3,
2872
- geometry: "line",
2873
- geometryTypes: ["MultiLineString", "Polygon"],
2874
- drawRule: "Line3",
2875
- params: [
2876
- {
2877
- key: "hSize",
2878
- label: "Head size",
2879
- description: "Arrowhead length as a fraction of each arm's length",
2880
- type: "number",
2881
- min: .01,
2882
- max: .5,
2883
- step: .01
2884
- },
2885
- {
2886
- key: "hWidth",
2887
- label: "Head width",
2888
- description: "Arrowhead wing spread relative to the arrowhead length",
2889
- type: "number",
2890
- min: .05,
2891
- max: 2,
2892
- step: .01
2893
- },
2894
- {
2895
- key: "bladeHeight",
2896
- label: "Blade height",
2897
- description: "Blade's perpendicular reach into the V, as a fraction of its arm's length",
2898
- type: "number",
2899
- min: .01,
2900
- max: .5,
2901
- step: .01
2902
- }
2903
- ]
2904
- };
2905
- const FINAL_PROTECTIVE_FIRE_LEFT_METADATA = {
2906
- ...FPF_SHARED,
2907
- id: "final-protective-fire-left",
2908
- name: "Final Protective Fire (Left)",
2909
- description: "An immediately available, prearranged barrier of fire designed to impede enemy movement across defensive lines or areas. The blade marks the left arm (PT 1 to PT 2).",
2910
- entitySubtype: "Final Protective Fire (Left)",
2911
- value: "140501"
2912
- };
2913
- const FINAL_PROTECTIVE_FIRE_RIGHT_METADATA = {
2914
- ...FPF_SHARED,
2915
- id: "final-protective-fire-right",
2916
- name: "Final Protective Fire (Right)",
2917
- description: "An immediately available, prearranged barrier of fire designed to impede enemy movement across defensive lines or areas. The blade marks the right arm (PT 1 to PT 3).",
2918
- entitySubtype: "Final Protective Fire (Right)",
2919
- value: "140502"
2920
- };
2921
- /**
2922
- * Builds the filled "blade": a uniform-width rectangle hugging the inside of
2923
- * one arm. One long edge lies on the central 80% of the arm line (connected to
2924
- * the line); the strip bulges perpendicularly into the inside of the V by
2925
- * `bladeHeight * armLength`.
2926
- *
2927
- * @param vertex - PT 1, the shared vertex of the V (projected)
2928
- * @param tip - the outward tip of the bladed arm (projected)
2929
- * @param otherTip - the opposite arm's tip, used to find the "inside" side; may
2930
- * be undefined during live single-arm feedback, in which case a default side
2931
- * is chosen
2932
- */
2933
- function bladeRectangle(vertex, tip, otherTip, bladeHeight) {
2934
- const v = vecSub(tip, vertex);
2935
- const armLength = vecMag(v);
2936
- if (armLength < 1e-6) return null;
2937
- const dir = vecNorm(v);
2938
- let perp = [-dir[1], dir[0]];
2939
- if (otherTip) {
2940
- const toOther = vecSub(otherTip, vertex);
2941
- if (vecDot(perp, toOther) < 0) perp = vecScale(perp, -1);
2942
- }
2943
- const gap = (1 - BLADE_LENGTH_FRACTION) / 2;
2944
- const onLineNear = vecAdd(vertex, vecScale(dir, armLength * gap));
2945
- const onLineFar = vecAdd(vertex, vecScale(dir, armLength * (1 - gap)));
2946
- const height = vecScale(perp, armLength * bladeHeight);
2947
- return [
2948
- onLineNear,
2949
- onLineFar,
2950
- vecAdd(onLineFar, height),
2951
- vecAdd(onLineNear, height)
2952
- ];
2953
- }
2954
- /**
2955
- * Generates a GeoJSON FeatureCollection for a FINAL PROTECTIVE FIRE tactical
2956
- * symbol: the Principal-Direction-of-Fire "V" (arms + open arrowheads) plus a
2957
- * filled blade along the inside of one arm.
2958
- *
2959
- * Renders one arm per tip, so two points (vertex + one tip) draws the single
2960
- * arm being placed — the live feedback after PT 1 — and three points draws the
2961
- * full V. Points beyond the third are ignored (input contract, ADR-0014).
2962
- *
2963
- * @param coordinates - [PT 1 (vertex), PT 2 (tip), PT 3 (tip)]
2964
- * @param bladedArm - which arm carries the blade: 0 = PT 2 (left), 1 = PT 3 (right)
2965
- * @param options - Arrowhead and blade sizing parameters
2966
- */
2967
- function createFinalProtectiveFire(coordinates, bladedArm, options = {}) {
2968
- const { hSize, hWidth, bladeHeight } = {
2969
- ...DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS,
2970
- ...options
2971
- };
2972
- const v = buildFieldOfFireV(coordinates, Math.max(0, hSize), Math.max(0, hWidth));
2973
- if (!v) return {
2974
- type: "FeatureCollection",
2975
- features: []
2976
- };
2977
- const features = [{
2978
- type: "Feature",
2979
- properties: {},
2980
- geometry: {
2981
- type: "MultiLineString",
2982
- coordinates: v.lines.map((line) => line.map((c) => unproject(c[0], c[1])))
2983
- }
2984
- }];
2985
- const bladeTip = v.tips[bladedArm];
2986
- if (bladeTip) {
2987
- const otherTip = v.tips[bladedArm === 0 ? 1 : 0];
2988
- const blade = bladeRectangle(v.vertex, bladeTip, otherTip, Math.max(0, bladeHeight));
2989
- if (blade) {
2990
- const ring = [...blade, blade[0]].map((c) => unproject(c[0], c[1]));
2991
- features.push({
2992
- type: "Feature",
2993
- properties: {
2994
- part: "blade",
2995
- fill: true
2996
- },
2997
- geometry: {
2998
- type: "Polygon",
2999
- coordinates: [ring]
3000
- }
3001
- });
3002
- }
3003
- }
3004
- return {
3005
- type: "FeatureCollection",
3006
- features
3007
- };
3008
- }
3009
- function createFinalProtectiveFireLeft(coordinates, options = {}) {
3010
- return createFinalProtectiveFire(coordinates, 0, options);
3011
- }
3012
- function createFinalProtectiveFireRight(coordinates, options = {}) {
3013
- return createFinalProtectiveFire(coordinates, 1, options);
3014
- }
3015
- const FINAL_PROTECTIVE_FIRE_LEFT = defineControlMeasure({
3016
- metadata: FINAL_PROTECTIVE_FIRE_LEFT_METADATA,
3017
- generator: createFinalProtectiveFireLeft,
3018
- defaultOptions: DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS,
3019
- rule: line3DrawRule
3020
- });
3021
- const FINAL_PROTECTIVE_FIRE_RIGHT = defineControlMeasure({
3022
- metadata: FINAL_PROTECTIVE_FIRE_RIGHT_METADATA,
3023
- generator: createFinalProtectiveFireRight,
3024
- defaultOptions: DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS,
3025
- rule: line3DrawRule
3026
- });
3027
- //#endregion
3028
- //#region src/generators/cm27-protection-areas/fix.ts
3029
- const DEFAULT_FIX_OPTIONS = {
3030
- arrowHeadAngle: 35,
3031
- arrowHeadSizeRatio: .16,
3032
- endSegmentRatio: .2
3033
- };
3034
- const FIX_METADATA = {
3035
- id: "fix",
3036
- name: "Fix",
3037
- description: "Fix task symbol with zigzag shaft and open arrowhead.",
3038
- entity: "Protection Areas",
3039
- entityType: "Obstacle Effects",
3040
- entitySubtype: "Fix",
3041
- value: "270503",
3042
- minCoordinates: 2,
3043
- maxCoordinates: 2,
3044
- geometry: "line",
3045
- geometryTypes: ["MultiLineString"],
3046
- drawRule: "Line9",
3047
- params: [
3048
- {
3049
- key: "arrowHeadAngle",
3050
- label: "Arrowhead angle",
3051
- description: "Half-angle of the open arrowhead wings, in degrees.",
3052
- type: "number",
3053
- min: 0,
3054
- max: 90,
3055
- step: 1
3056
- },
3057
- {
3058
- key: "arrowHeadSizeRatio",
3059
- label: "Arrowhead size",
3060
- description: "Wing length as a ratio of the overall axis length.",
3061
- type: "number",
3062
- min: .01,
3063
- max: 1,
3064
- step: .01
3065
- },
3066
- {
3067
- key: "endSegmentRatio",
3068
- label: "End segment",
3069
- description: "Flat end segment length as a ratio of the axis (capped at 0.35).",
3070
- type: "number",
3071
- min: 0,
3072
- max: 1,
3073
- step: .01
3074
- }
3075
- ]
3076
- };
3077
- /**
3078
- * Generates a GeoJSON FeatureCollection for a FIX tactical symbol.
3079
- *
3080
- * Input:
3081
- * - PT1: Arrow tip end
3082
- * - PT2: Opposite end
3083
- *
3084
- * Notes:
3085
- * - Requires exactly two points.
3086
- * - Produces one MultiLineString feature containing the zigzag body and open arrowhead wings.
3087
- */
3088
- function createFix(coordinates, options = {}) {
3089
- const c1 = coordinates[0];
3090
- const c2 = coordinates[1];
3091
- const arrowHeadAngle = options.arrowHeadAngle ?? DEFAULT_FIX_OPTIONS.arrowHeadAngle;
3092
- const arrowHeadSizeRatio = options.arrowHeadSizeRatio ?? DEFAULT_FIX_OPTIONS.arrowHeadSizeRatio;
3093
- const endSegmentRatio = options.endSegmentRatio ?? DEFAULT_FIX_OPTIONS.endSegmentRatio;
3094
- const pTip = project(c1[0], c1[1]);
3095
- const pTail = project(c2[0], c2[1]);
3096
- const axis = vecSub(pTip, pTail);
3097
- const axisLength = vecMag(axis);
3098
- if (axisLength < 1e-6) return {
3099
- type: "FeatureCollection",
3100
- features: []
3101
- };
3102
- const dir = vecNorm(axis);
3103
- const perp = [-dir[1], dir[0]];
3104
- const flatLength = Math.min(axisLength * endSegmentRatio, axisLength * .35);
3105
- const zigStart = vecAdd(pTail, vecScale(dir, flatLength));
3106
- const zigEnd = vecSub(pTip, vecScale(dir, flatLength));
3107
- const zigLength = vecMag(vecSub(zigEnd, zigStart));
3108
- if (zigLength < 1e-6) return {
3109
- type: "FeatureCollection",
3110
- features: []
3111
- };
3112
- const headLength = Math.max(axisLength * arrowHeadSizeRatio, EPSILON);
3113
- const headAngleRad = arrowHeadAngle * Math.PI / 180;
3114
- const amplitude = Math.max(Math.abs(headLength * Math.sin(headAngleRad)), EPSILON);
3115
- const zigP1 = vecAdd(zigStart, vecAdd(vecScale(dir, zigLength * .2), vecScale(perp, -amplitude)));
3116
- const zigP2 = vecAdd(zigStart, vecAdd(vecScale(dir, zigLength * .5), vecScale(perp, amplitude)));
3117
- const zigP3 = vecAdd(zigStart, vecAdd(vecScale(dir, zigLength * .8), vecScale(perp, -amplitude)));
3118
- const mainLine = [
3119
- unproject(pTail[0], pTail[1]),
3120
- unproject(zigStart[0], zigStart[1]),
3121
- unproject(zigP1[0], zigP1[1]),
3122
- unproject(zigP2[0], zigP2[1]),
3123
- unproject(zigP3[0], zigP3[1]),
3124
- unproject(zigEnd[0], zigEnd[1]),
3125
- unproject(pTip[0], pTip[1])
3126
- ];
3127
- const backDir = vecScale(dir, -1);
3128
- const wing1Dir = rotate$1(backDir, headAngleRad);
3129
- const wing2Dir = rotate$1(backDir, -headAngleRad);
3130
- const wing1End = vecAdd(pTip, vecScale(wing1Dir, headLength));
3131
- const wing2End = vecAdd(pTip, vecScale(wing2Dir, headLength));
3132
- return {
3133
- type: "FeatureCollection",
3134
- features: [{
3135
- type: "Feature",
3136
- properties: {},
3137
- geometry: {
3138
- type: "MultiLineString",
3139
- coordinates: [
3140
- mainLine,
3141
- [unproject(pTip[0], pTip[1]), unproject(wing1End[0], wing1End[1])],
3142
- [unproject(pTip[0], pTip[1]), unproject(wing2End[0], wing2End[1])]
3143
- ]
3144
- }
3145
- }]
3146
- };
3147
- }
3148
- function rotate$1(v, angle) {
3149
- const cosA = Math.cos(angle);
3150
- const sinA = Math.sin(angle);
3151
- return [v[0] * cosA - v[1] * sinA, v[0] * sinA + v[1] * cosA];
3152
- }
3153
- const FIX = defineControlMeasure({
3154
- metadata: FIX_METADATA,
3155
- generator: createFix,
3156
- defaultOptions: DEFAULT_FIX_OPTIONS,
3157
- rule: line9DrawRule
3158
- });
3159
- //#endregion
3160
- //#region src/generators/cm14-maneuver-lines/flot.ts
3161
- const DEFAULT_RADIUS = 50;
3162
- const DEFAULT_RADIUS_PIXELS = 10;
3163
- const DEFAULT_RESOLUTION = 16;
3164
- /**
3165
- * Default options for the FLOT graphic.
3166
- */
3167
- const DEFAULT_FLOT_OPTIONS = {
3168
- radius: DEFAULT_RADIUS,
3169
- radiusPixels: DEFAULT_RADIUS_PIXELS,
3170
- resolution: DEFAULT_RESOLUTION
3171
- };
3172
- const FLOT_METADATA = {
3173
- id: "flot",
3174
- name: "Forward line of own troops (FLOT)",
3175
- description: "Indicates the most forward positions of friendly forces at a specific time.",
3176
- entity: "Maneuver Lines",
3177
- entityType: "Forward Line of Own Troops",
3178
- value: "140100",
3179
- minCoordinates: 2,
3180
- geometry: "line",
3181
- geometryTypes: ["LineString"],
3182
- drawRule: "Line1",
3183
- params: [
3184
- {
3185
- key: "radiusPixels",
3186
- label: "Radius",
3187
- description: "Radius of each scallop arc in screen pixels",
3188
- type: "number",
3189
- min: 5,
3190
- max: 50,
3191
- step: 1,
3192
- unit: "px"
3193
- },
3194
- {
3195
- key: "radius",
3196
- label: "Radius",
3197
- description: "Radius of each scallop arc in meters",
3198
- type: "number",
3199
- min: 1,
3200
- max: 500,
3201
- step: 1,
3202
- unit: "m"
3203
- },
3204
- {
3205
- key: "resolution",
3206
- label: "Resolution",
3207
- description: "Number of points used to render each semicircle",
3208
- type: "number",
3209
- min: 4,
3210
- max: 32,
3211
- step: 2
3212
- }
3213
- ]
3214
- };
3215
- /**
3216
- * Generates points for a true semicircle arc along a segment.
3217
- *
3218
- * The semicircle is centered at the midpoint of the diameter span,
3219
- * with the specified radius. Points are generated by rotating
3220
- * from PI to 0 around this center.
3221
- *
3222
- * @param startPoint - Start point of the arc on the segment
3223
- * @param segmentDir - Direction vector of the segment (normalized)
3224
- * @param perpDir - Perpendicular direction vector (normalized)
3225
- * @param radius - Radius of the semicircle
3226
- * @param resolution - Number of points in the arc
3227
- * @param includeStart - Whether to include the start point
3228
- * @returns Array of arc points forming a true semicircle
3229
- */
3230
- function generateArcPoints(startPoint, segmentDir, perpDir, radius, resolution, includeStart) {
3231
- const points = [];
3232
- const startIndex = includeStart ? 0 : 1;
3233
- const center = vecAdd(startPoint, vecScale(segmentDir, radius));
3234
- for (let i = startIndex; i <= resolution; i++) {
3235
- const t = i / resolution;
3236
- const angle = Math.PI * (1 - t);
3237
- const alongSegment = Math.cos(angle) * radius;
3238
- const perpOffset = Math.sin(angle) * radius;
3239
- const point = vecAdd(vecAdd(center, vecScale(segmentDir, alongSegment)), vecScale(perpDir, perpOffset));
3240
- points.push(point);
3241
- }
3242
- return points;
3243
- }
3244
- /**
3245
- * Processes a single segment between two positions, generating scalloped arcs.
3246
- *
3247
- * @param p1 - Start position (projected)
3248
- * @param p2 - End position (projected)
3249
- * @param radius - Radius of each semicircle
3250
- * @param resolution - Points per arc
3251
- * @param isFirstSegment - Whether this is the first segment (include start point)
3252
- * @returns Array of points forming the scalloped line for this segment
3253
- */
3254
- function processSegment(p1, p2, radius, resolution, isFirstSegment) {
3255
- const segmentVec = vecSub(p2, p1);
3256
- const segmentLength = vecMag(segmentVec);
3257
- if (segmentLength < 1e-6) return isFirstSegment ? [p1] : [];
3258
- const segmentDir = vecNorm(segmentVec);
3259
- const perpDir = [-segmentDir[1], segmentDir[0]];
3260
- const points = [];
3261
- const diameter = 2 * radius;
3262
- const numFullArcs = Math.floor(segmentLength / diameter);
3263
- const remainingLength = segmentLength - numFullArcs * diameter;
3264
- let currentPos = p1;
3265
- for (let arcIndex = 0; arcIndex < numFullArcs; arcIndex++) {
3266
- const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, radius, resolution, arcIndex === 0 && isFirstSegment);
3267
- points.push(...arcPoints);
3268
- currentPos = vecAdd(currentPos, vecScale(segmentDir, diameter));
3269
- }
3270
- if (remainingLength > diameter * .1) {
3271
- const partialRadius = remainingLength / 2;
3272
- const partialResolution = Math.max(3, Math.round(resolution * (partialRadius / radius)));
3273
- const arcPoints = generateArcPoints(currentPos, segmentDir, perpDir, partialRadius, partialResolution, numFullArcs === 0 && isFirstSegment);
3274
- points.push(...arcPoints);
3275
- }
3276
- return points;
3277
- }
3278
- /**
3279
- * Creates a FLOT (Forward Line of Own Troops) tactical graphic.
3280
- *
3281
- * Generates a scalloped/arc line representing the forward edge of friendly forces.
3282
- * The line consists of semi-circular arcs that bulge perpendicular to the base path,
3283
- * creating a distinctive visual pattern used in military tactical graphics.
3284
- *
3285
- * @param positions - Array of GeoJSON positions defining the base path
3286
- * @param options - Configuration options for the graphic
3287
- * @returns A GeoJSON FeatureCollection containing the scalloped LineString
3288
- *
3289
- * @example
3290
- * ```typescript
3291
- * const flot = createFLOT(
3292
- * [[-122.4194, 37.7749], [-122.4100, 37.7800], [-122.4000, 37.7750]],
3293
- * { radius: 100, resolution: 20 }
3294
- * );
3295
- * ```
3296
- */
3297
- function createFLOT(positions, options = {}) {
3298
- const { radius = DEFAULT_RADIUS, radiusPixels, metersPerPixel, resolution = DEFAULT_RESOLUTION } = options;
3299
- let effectiveRadius = radius;
3300
- if (radiusPixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveRadius = radiusPixels * metersPerPixel;
3301
- const safeRadius = Math.max(EPSILON, effectiveRadius);
3302
- const safeResolution = Math.max(3, Math.round(resolution));
3303
- const projectedPoints = positions.map((p) => project(p[0], p[1]));
3304
- const allPoints = [];
3305
- for (let i = 0; i < projectedPoints.length - 1; i++) {
3306
- const p1 = projectedPoints[i];
3307
- const p2 = projectedPoints[i + 1];
3308
- const segmentPoints = processSegment(p1, p2, safeRadius, safeResolution, i === 0);
3309
- allPoints.push(...segmentPoints);
3310
- }
3311
- if (allPoints.length > 0 && projectedPoints.length > 1) {
3312
- const lastOriginal = projectedPoints[projectedPoints.length - 1];
3313
- const lastGenerated = allPoints[allPoints.length - 1];
3314
- if (vecMag(vecSub(lastOriginal, lastGenerated)) > 1e-6) allPoints.push(lastOriginal);
3315
- }
3316
- return {
3317
- type: "FeatureCollection",
3318
- features: [{
3319
- type: "Feature",
3320
- properties: {},
3321
- geometry: {
3322
- type: "LineString",
3323
- coordinates: allPoints.map((p) => unproject(p[0], p[1]))
3324
- }
3325
- }]
3326
- };
3327
- }
3328
- const FLOT = defineControlMeasure({
3329
- metadata: FLOT_METADATA,
3330
- generator: createFLOT,
3331
- defaultOptions: DEFAULT_FLOT_OPTIONS,
3332
- rule: line1DrawRule
3333
- });
3334
- //#endregion
3335
- //#region src/generators/cm29-protection-lines/fortifiedLine.ts
3336
- const DEFAULT_SIZE = 50;
3337
- /**
3338
- * Default options for the Fortified Line graphic.
3339
- */
3340
- const DEFAULT_FORTIFIED_LINE_OPTIONS = {
3341
- size: DEFAULT_SIZE,
3342
- sizePixels: 10
3343
- };
3344
- const FORTIFIED_LINE_METADATA = {
3345
- id: "fortified-line",
3346
- name: "Fortified Line",
3347
- description: "Fortified line with castellated square-wave boundary.",
3348
- entity: "Protection Lines",
3349
- entityType: "Fortified Line",
3350
- value: "290900",
3351
- minCoordinates: 2,
3352
- geometry: "line",
3353
- geometryTypes: ["LineString"],
3354
- drawRule: "Line1",
3355
- params: FORTIFIED_PARAMS
3356
- };
3357
- /**
3358
- * Generates points for a square wave "tooth" along a segment.
3359
- *
3360
- * A tooth consists of:
3361
- * 1. Move along segment by length/4 (flat)
3362
- * 2. Move out perpendicular by height (rise)
3363
- * 3. Move along segment direction by length/2 (top)
3364
- * 4. Move back perpendicular by height (drop)
3365
- * 5. Move along segment by length/4 (flat)
3366
- *
3367
- * Original 'size' logic (where length=2*size, height=size):
3368
- * - Flat1: size/2 = length/4
3369
- * - Rise: size = height
3370
- * - Top: size = length/2
3371
- * - Drop: size = height
3372
- * - Flat2: size/2 = length/4
3373
- *
3374
- * Total length consumed on segment = length
3375
- *
3376
- * @param startPoint - Start point of the tooth on the segment
3377
- * @param segmentDir - Direction vector of the segment (normalized)
3378
- * @param perpDir - Perpendicular direction vector (normalized)
3379
- * @param length - Length of the tooth along the segment (period)
3380
- * @param height - Height of the tooth (amplitude)
3381
- * @returns Array of points forming a square tooth
3382
- */
3383
- function generateSquareToothPoints(startPoint, segmentDir, perpDir, length, height) {
3384
- const points = [];
3385
- const p1 = vecAdd(startPoint, vecScale(segmentDir, length / 4));
3386
- points.push(p1);
3387
- const p2 = vecAdd(p1, vecScale(perpDir, height));
3388
- points.push(p2);
3389
- const p3 = vecAdd(p2, vecScale(segmentDir, length / 2));
3390
- points.push(p3);
3391
- const p4 = vecAdd(p3, vecScale(perpDir, -height));
3392
- points.push(p4);
3393
- return points;
3394
- }
3395
- /**
3396
- * Processes a single segment between two positions, generating square waves.
3397
- *
3398
- * @param p1 - Start position (projected)
3399
- * @param p2 - End position (projected)
3400
- * @param size - Size (height) of the square teeth. Also used for target length (2*size).
3401
- * @param isFirstSegment - Whether this is the first segment (include start point)
3402
- * @returns Array of points forming the castellated line for this segment
3403
- */
3404
- function processFortifiedSegment(p1, p2, size, isFirstSegment) {
3405
- const segmentVec = vecSub(p2, p1);
3406
- const segmentLength = vecMag(segmentVec);
3407
- if (segmentLength < 1e-6) return isFirstSegment ? [p1] : [];
3408
- const segmentDir = vecNorm(segmentVec);
3409
- const perpDir = [-segmentDir[1], segmentDir[0]];
3410
- const points = [];
3411
- if (isFirstSegment) points.push(p1);
3412
- const targetPeriod = 2 * size;
3413
- let numFullPeriods = Math.round(segmentLength / targetPeriod);
3414
- if (numFullPeriods < 1) numFullPeriods = 1;
3415
- const actualPeriod = segmentLength / numFullPeriods;
3416
- const height = size;
3417
- let currentPos = p1;
3418
- for (let i = 0; i < numFullPeriods; i++) {
3419
- const toothPoints = generateSquareToothPoints(currentPos, segmentDir, perpDir, actualPeriod, height);
3420
- points.push(...toothPoints);
3421
- currentPos = vecAdd(currentPos, vecScale(segmentDir, actualPeriod));
3422
- points.push(currentPos);
3423
- }
3424
- return points;
3425
- }
3426
- /**
3427
- * Calculates the effective size based on options and projection.
3428
- */
3429
- function calculateEffectiveSize(options) {
3430
- const { size = DEFAULT_SIZE, sizePixels, metersPerPixel } = options;
3431
- let effectiveSize = size;
3432
- if (sizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) effectiveSize = sizePixels * metersPerPixel;
3433
- return Math.max(EPSILON, effectiveSize);
3434
- }
3435
- /**
3436
- * Generates the raw points for a fortified line/area boundary.
3437
- */
3438
- function generateFortifiedPoints(projectedPoints, effectiveSize) {
3439
- const allPoints = [];
3440
- for (let i = 0; i < projectedPoints.length - 1; i++) {
3441
- const p1 = projectedPoints[i];
3442
- const p2 = projectedPoints[i + 1];
3443
- const segmentPoints = processFortifiedSegment(p1, p2, effectiveSize, i === 0);
3444
- allPoints.push(...segmentPoints);
3445
- }
3446
- if (allPoints.length > 0 && projectedPoints.length > 1) {
3447
- const lastOriginal = projectedPoints[projectedPoints.length - 1];
3448
- const lastGenerated = allPoints[allPoints.length - 1];
3449
- if (vecMag(vecSub(lastOriginal, lastGenerated)) > 1e-6) allPoints.push(lastOriginal);
3450
- }
3451
- return allPoints;
3452
- }
3453
- /**
3454
- * Creates a Fortified Line tactical graphic.
3455
- *
3456
- * Generates a castellated/square-wave line representing a fortified line.
3457
- * The line consists of square "teeth" that project perpendicular to the base path.
3458
- *
3459
- * @param positions - Array of GeoJSON positions defining the base path
3460
- * @param options - Configuration options for the graphic
3461
- * @returns A GeoJSON FeatureCollection containing the castellated LineString
3462
- */
3463
- function createFortifiedLine(positions, options = {}) {
3464
- const safeSize = calculateEffectiveSize(options);
3465
- return {
3466
- type: "FeatureCollection",
3467
- features: [{
3468
- type: "Feature",
3469
- properties: {},
3470
- geometry: {
3471
- type: "LineString",
3472
- coordinates: generateFortifiedPoints(positions.map((p) => project(p[0], p[1])), safeSize).map((p) => unproject(p[0], p[1]))
3473
- }
3474
- }]
3475
- };
3476
- }
3477
- const FORTIFIED_LINE = defineControlMeasure({
3478
- metadata: FORTIFIED_LINE_METADATA,
3479
- generator: createFortifiedLine,
3480
- defaultOptions: DEFAULT_FORTIFIED_LINE_OPTIONS,
3481
- rule: line1DrawRule
3482
- });
3483
- //#endregion
3484
- //#region src/generators/cm15-maneuver-areas/fortifiedArea.ts
3485
- const DEFAULT_FORTIFIED_AREA_OPTIONS = DEFAULT_FORTIFIED_LINE_OPTIONS;
3486
- const FORTIFIED_AREA_METADATA = {
3487
- id: "fortified-area",
3488
- name: "Fortified Area",
3489
- description: "A specified geographical locality with constructed defensive works as protection against attack.",
3490
- entity: "Maneuver Areas",
3491
- entityType: "Fortified Area",
3492
- value: "151000",
3493
- minCoordinates: 3,
3494
- geometry: "area",
3495
- geometryTypes: ["Polygon"],
3496
- drawRule: "Area1",
3497
- params: FORTIFIED_PARAMS$1
3498
- };
3499
- /**
3500
- * Creates a Fortified Area tactical graphic.
3501
- *
3502
- * Generates a polygon with a castellated/square-wave boundary representing a fortified area.
3503
- * The boundary consists of square "teeth" that project perpendicular to the path.
3504
- *
3505
- * @param positions - Array of GeoJSON positions defining the area boundary.
3506
- * The first and last positions should match to close the polygon,
3507
- * or it will be closed automatically.
3508
- * @param options - Configuration options for the graphic
3509
- * @returns A GeoJSON FeatureCollection containing the castellated Polygon
3510
- */
3511
- function createFortifiedArea(positions, options = {}) {
3512
- const safeSize = calculateEffectiveSize(options);
3513
- const validPositions = positions;
3514
- const first = validPositions[0];
3515
- const last = validPositions[validPositions.length - 1];
3516
- const boundaryPoints = generateFortifiedPoints((Math.abs(first[0] - last[0]) < Number.EPSILON && Math.abs(first[1] - last[1]) < Number.EPSILON ? validPositions : [...validPositions, first]).map((p) => project(p[0], p[1])), safeSize);
3517
- if (boundaryPoints.length > 0) {
3518
- const firstPt = boundaryPoints[0];
3519
- const lastPt = boundaryPoints[boundaryPoints.length - 1];
3520
- if (vecMag(vecSub(firstPt, lastPt)) > 1e-6) boundaryPoints.push(firstPt);
3521
- else boundaryPoints[boundaryPoints.length - 1] = firstPt;
3522
- }
3523
- return {
3524
- type: "FeatureCollection",
3525
- features: [{
3526
- type: "Feature",
3527
- properties: {},
3528
- geometry: {
3529
- type: "Polygon",
3530
- coordinates: [boundaryPoints.map((p) => unproject(p[0], p[1]))]
3531
- }
3532
- }]
3533
- };
3534
- }
3535
- const FORTIFIED_AREA = defineControlMeasure({
3536
- metadata: FORTIFIED_AREA_METADATA,
3537
- generator: createFortifiedArea,
3538
- defaultOptions: DEFAULT_FORTIFIED_AREA_OPTIONS
3539
- });
3540
- //#endregion
3541
- //#region src/generators/cm34-mission-tasks/isolate.ts
3542
- /**
3543
- * Default options for the ISOLATE symbol.
3544
- */
3545
- const DEFAULT_ISOLATE_OPTIONS = {
3546
- openingDegrees: 30,
3547
- barbCount: 9,
3548
- barbLengthRatio: .18
3549
- };
3550
- const ISOLATE_METADATA = {
3551
- id: "isolate",
3552
- name: "Isolate",
3553
- description: "Isolate is a tactical mission task in which a unit seals off an enemy, physically and psychologically, from sources of support and denies it freedom of movement.",
3554
- entity: "Mission Tasks",
3555
- entityType: "Isolate",
3556
- value: "341500",
3557
- minCoordinates: 2,
3558
- maxCoordinates: 2,
3559
- geometry: "line",
3560
- geometryTypes: ["MultiLineString"],
3561
- drawRule: "Area15",
3562
- params: [
3563
- {
3564
- key: "openingDegrees",
3565
- label: "Opening",
3566
- description: "Angular width of the gap on the friendly side, in degrees.",
3567
- type: "number",
3568
- min: 0,
3569
- max: 180,
3570
- step: 1
3571
- },
3572
- {
3573
- key: "barbCount",
3574
- label: "Barbs",
3575
- description: "Number of inward-pointing barbs spaced along the closed arc.",
3576
- type: "number",
3577
- min: 1,
3578
- max: 24,
3579
- step: 1
3580
- },
3581
- {
3582
- key: "barbLengthRatio",
3583
- label: "Barb length",
3584
- description: "Length of each inward barb, as a ratio of the symbol radius.",
3585
- type: "number",
3586
- min: .01,
3587
- max: 1,
3588
- step: .01
3589
- }
3590
- ]
3591
- };
3592
- /**
3593
- * Generates a GeoJSON FeatureCollection for an ISOLATE mission task symbol (341500).
3594
- *
3595
- * A near-complete circle centered on PT. 1 whose radius reaches PT. 2. The arc
3596
- * starts (bare) at PT. 2's bearing and sweeps clockwise around the closed
3597
- * portion to a single arrow tip at the far end; the wedge-shaped gap (default
3598
- * 30°) between that arrow tip and PT. 2 is the opening — the "friendly side".
3599
- * Inward-pointing arrowhead barbs are spaced along the closed arc, set in from
3600
- * both ends so they never touch PT. 2 or the arrow tip, conveying the
3601
- * sealing-off of the enclosed force. Unlike the bracket mission tasks, isolate
3602
- * carries no glyph label.
3603
- *
3604
- * Input Points:
3605
- * - PT. 1: Center point
3606
- * - PT. 2: Start point — fixes the radius and the bearing of the opening
3607
- *
3608
- * @param coordinates - [PT. 1, PT. 2] as Position arrays
3609
- * @param options - Configuration options
3610
- * @returns GeoJSON FeatureCollection containing a single MultiLineString feature
3611
- */
3612
- function createIsolateSymbol(coordinates, options = {}) {
3613
- const openingDegrees = options.openingDegrees ?? DEFAULT_ISOLATE_OPTIONS.openingDegrees;
3614
- const barbCount = options.barbCount ?? DEFAULT_ISOLATE_OPTIONS.barbCount;
3615
- const barbLengthRatio = options.barbLengthRatio ?? DEFAULT_ISOLATE_OPTIONS.barbLengthRatio;
3616
- const center = project(coordinates[0][0], coordinates[0][1]);
3617
- const vRadius = vecSub(project(coordinates[1][0], coordinates[1][1]), center);
3618
- const radius = vecMag(vRadius);
3619
- if (radius < 1e-6) return {
3620
- type: "FeatureCollection",
3621
- features: []
3622
- };
3623
- const startAngle = Math.atan2(vRadius[1], vRadius[0]);
3624
- const openingRad = Math.min(Math.max(openingDegrees, 0), 359) * Math.PI / 180;
3625
- const arcSpan = 2 * Math.PI - openingRad;
3626
- const endAngle = startAngle - arcSpan;
3627
- const pointOnCircle = (angle, r = radius) => vecAdd(center, [r * Math.cos(angle), r * Math.sin(angle)]);
3628
- const segments = Math.max(8, Math.round(64 * arcSpan / (2 * Math.PI)));
3629
- const arc = [];
3630
- for (let i = 0; i <= segments; i++) {
3631
- const p = pointOnCircle(startAngle - arcSpan * i / segments);
3632
- arc.push(unproject(p[0], p[1]));
3633
- }
3634
- const barbLength = radius * barbLengthRatio;
3635
- const lines = [arc];
3636
- const tipDir = [Math.sin(endAngle), -Math.cos(endAngle)];
3637
- lines.push(createArrowHead$1(pointOnCircle(endAngle), tipDir, barbLength, barbLength));
3638
- const halfWidth = Math.asin(Math.min(1, barbLength / 2 / radius));
3639
- const tipRadius = Math.max(0, radius - barbLength);
3640
- const steps = Math.max(1, barbCount);
3641
- for (let k = 1; k <= steps; k++) {
3642
- const angle = startAngle - arcSpan * k / (steps + 1);
3643
- const left = pointOnCircle(angle - halfWidth);
3644
- const right = pointOnCircle(angle + halfWidth);
3645
- const tip = pointOnCircle(angle, tipRadius);
3646
- lines.push([
3647
- unproject(left[0], left[1]),
3648
- unproject(tip[0], tip[1]),
3649
- unproject(right[0], right[1])
3650
- ]);
3651
- }
3652
- return {
3653
- type: "FeatureCollection",
3654
- features: [{
3655
- type: "Feature",
3656
- properties: { part: "isolate" },
3657
- geometry: {
3658
- type: "MultiLineString",
3659
- coordinates: lines
3660
- }
3661
- }]
3662
- };
3663
- }
3664
- const ISOLATE = defineControlMeasure({
3665
- metadata: ISOLATE_METADATA,
3666
- generator: createIsolateSymbol,
3667
- defaultOptions: DEFAULT_ISOLATE_OPTIONS,
3668
- rule: isolateDrawRule
3669
- });
3670
- //#endregion
3671
- //#region src/generators/cm15-maneuver-areas/mainAttack.ts
3672
- const DEFAULT_MAIN_ATTACK_OPTIONS = {
3673
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
3674
- roundedBends: false,
3675
- bendSegments: 5
3676
- };
3677
- const MAIN_ATTACK_METADATA = {
3678
- id: "main-attack",
3679
- name: "Main Attack",
3680
- description: "Primary offensive attack graphic.",
3681
- entity: "Maneuver Areas",
3682
- entityType: "Axis of Advance",
3683
- entitySubtype: "Main Attack",
3684
- value: "151403",
3685
- minCoordinates: 3,
3686
- geometry: "line",
3687
- geometryTypes: ["MultiLineString"],
3688
- drawRule: "Axis1",
3689
- params: ATTACK_SHAFT_PARAMS
3690
- };
3691
- /**
3692
- * Generates a GeoJSON FeatureCollection for a Main Attack tactical symbol.
3693
- *
3694
- * The symbol consists of a single MultiLineString feature containing:
3695
- * 1. **Arrowhead**: A line forming a "chevron" or "roof" shape.
3696
- * 2. **Shaft**: Two parallel lines behind the arrow.
3697
- *
3698
- * The shaft is calculated to terminate exactly where it touches the inner walls
3699
- * of the arrowhead, creating a seamless connection.
3700
- *
3701
- * @param coordinates - An array of GeoJSON Positions (Lon/Lat).
3702
- * Minimum 3 points required.
3703
- * **Order matters:** * - Index 0: **Tip** (The sharp point of the arrow)
3704
- * - Index 1 to N-2: **Spine** (The path the shaft follows)
3705
- * - Index N-1: **Width Control** (Determines the width of the arrowhead)
3706
- * @param options - Configuration for ratios and properties.
3707
- * @returns A GeoJSON FeatureCollection containing a single MultiLineString.
3708
- */
3709
- function createMainAttack(coordinates, options = {}) {
3710
- const { geometry } = processAttackGeometry(coordinates, options);
3711
- if (!geometry) return {
3712
- type: "FeatureCollection",
3713
- features: []
3714
- };
3715
- return {
3716
- type: "FeatureCollection",
3717
- features: [{
3718
- type: "Feature",
3719
- properties: {},
3720
- geometry: {
3721
- type: "MultiLineString",
3722
- coordinates: [
3723
- geometry.shaftLeft.map((p) => unproject(p[0], p[1])),
3724
- geometry.shaftRight.map((p) => unproject(p[0], p[1])),
3725
- geometry.headRing.map((p) => unproject(p[0], p[1]))
3726
- ]
3727
- }
3728
- }]
3729
- };
3730
- }
3731
- const MAIN_ATTACK = defineControlMeasure({
3732
- metadata: MAIN_ATTACK_METADATA,
3733
- generator: createMainAttack,
3734
- defaultOptions: DEFAULT_MAIN_ATTACK_OPTIONS,
3735
- rule: axis1DrawRule
3736
- });
3737
- //#endregion
3738
- //#region src/generators/cm27-protection-areas/params.ts
3739
- const OBSTACLE_BYPASS_PARAMS = [
3740
- {
3741
- key: "arrowHeadLengthRatio",
3742
- label: "Arrowhead length",
3743
- description: "Arrowhead length as a ratio of the opening width.",
3744
- type: "number",
3745
- min: .05,
3746
- max: 1,
3747
- step: .01
3748
- },
3749
- {
3750
- key: "arrowHeadWidthRatio",
3751
- label: "Arrowhead width",
3752
- description: "Arrowhead base width as a ratio of the opening width.",
3753
- type: "number",
3754
- min: .05,
3755
- max: 1,
3756
- step: .01
3757
- },
3758
- {
3759
- key: "defaultLengthRatio",
3760
- label: "Default length",
3761
- description: "Symbol depth as a ratio of opening width when no hint point is given.",
3762
- type: "number",
3763
- min: .1,
3764
- max: 4,
3765
- step: .05
3766
- }
3767
- ];
3768
- //#endregion
3769
- //#region src/generators/cm27-protection-areas/obstacleBypassDifficult.ts
3770
- const DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS = {
3771
- arrowHeadLengthRatio: .2,
3772
- arrowHeadWidthRatio: .2,
3773
- defaultLengthRatio: 1.5,
3774
- zigzagAmplitudeRatio: .12,
3775
- zigzagSpacingRatio: .18
3776
- };
3777
- const OBSTACLE_BYPASS_DIFFICULT_METADATA = {
3778
- id: "obstacle-bypass-difficult",
3779
- name: "Obstacle Bypass Difficult",
3780
- description: "Obstacle bypass (difficult) with zigzag rear line and arrowheads.",
3781
- entity: "Protection Areas",
3782
- entityType: "Obstacle Bypass",
3783
- entitySubtype: "Difficult",
3784
- value: "270602",
3785
- minCoordinates: 2,
3786
- geometry: "line",
3787
- geometryTypes: ["MultiLineString", "MultiPolygon"],
3788
- drawRule: "Point12",
3789
- params: [
3790
- ...OBSTACLE_BYPASS_PARAMS,
3791
- {
3792
- key: "zigzagAmplitudeRatio",
3793
- label: "Zigzag amplitude",
3794
- description: "Zigzag tooth depth as a ratio of the opening width.",
3795
- type: "number",
3796
- min: .01,
3797
- max: 1,
3798
- step: .01
3799
- },
3800
- {
3801
- key: "zigzagSpacingRatio",
3802
- label: "Zigzag spacing",
3803
- description: "Spacing between zigzag teeth as a ratio of the opening width.",
3804
- type: "number",
3805
- min: .01,
3806
- max: 1,
3807
- step: .01
3808
- }
3809
- ]
3810
- };
3811
- function createObstacleBypassDifficult(coordinates, options = {}) {
3812
- const c1 = coordinates[0];
3813
- const c2 = coordinates[1];
3814
- const c3 = coordinates[2];
3815
- const arrowHeadLengthRatio = options.arrowHeadLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS.arrowHeadLengthRatio;
3816
- const arrowHeadWidthRatio = options.arrowHeadWidthRatio ?? DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS.arrowHeadWidthRatio;
3817
- const defaultLengthRatio = options.defaultLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS.defaultLengthRatio;
3818
- const zigzagAmplitudeRatio = options.zigzagAmplitudeRatio ?? DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS.zigzagAmplitudeRatio;
3819
- const zigzagSpacingRatio = options.zigzagSpacingRatio ?? DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS.zigzagSpacingRatio;
3820
- const p1 = project(c1[0], c1[1]);
3821
- const p2 = project(c2[0], c2[1]);
3822
- const openingVec = vecSub(p2, p1);
3823
- const openingLength = vecMag(openingVec);
3824
- if (openingLength < 1e-6) return {
3825
- type: "FeatureCollection",
3826
- features: []
3827
- };
3828
- const openingDir = vecNorm(openingVec);
3829
- const railDir = [openingDir[1], -openingDir[0]];
3830
- const openingMid = vecScale(vecAdd(p1, p2), .5);
3831
- let signedLength;
3832
- if (c3) {
3833
- const rawSignedLength = vecDot(vecSub(project(c3[0], c3[1]), openingMid), railDir);
3834
- if (Math.abs(rawSignedLength) < 1e-6) signedLength = (rawSignedLength >= 0 ? 1 : -1) * openingLength * defaultLengthRatio;
3835
- else signedLength = rawSignedLength;
3836
- } else signedLength = openingLength * defaultLengthRatio;
3837
- if (Math.abs(signedLength) < 1e-6) return {
3838
- type: "FeatureCollection",
3839
- features: []
3840
- };
3841
- const rearMid = vecAdd(openingMid, vecScale(railDir, signedLength));
3842
- const halfOpening = vecScale(openingDir, openingLength / 2);
3843
- const rearTop = vecSub(rearMid, halfOpening);
3844
- const rearBottom = vecAdd(rearMid, halfOpening);
3845
- const arrowHeadLength = Math.max(openingLength * arrowHeadLengthRatio, EPSILON);
3846
- const arrowHeadWidth = Math.max(openingLength * arrowHeadWidthRatio, EPSILON);
3847
- const topDir = vecNorm(vecSub(p1, rearTop));
3848
- const topHeadBase = vecSub(p1, vecScale(topDir, arrowHeadLength));
3849
- const bottomDir = vecNorm(vecSub(p2, rearBottom));
3850
- const bottomHeadBase = vecSub(p2, vecScale(bottomDir, arrowHeadLength));
3851
- const lineSegments = [
3852
- createZigzagRear(rearTop, rearBottom, vecScale(railDir, signedLength >= 0 ? -1 : 1), openingLength, zigzagAmplitudeRatio, zigzagSpacingRatio).map((point) => unproject(point[0], point[1])),
3853
- [unproject(rearTop[0], rearTop[1]), unproject(topHeadBase[0], topHeadBase[1])],
3854
- [unproject(rearBottom[0], rearBottom[1]), unproject(bottomHeadBase[0], bottomHeadBase[1])]
3855
- ];
3856
- const polygonRings = [[createArrowHeadRing(p1, topDir, arrowHeadLength, arrowHeadWidth)], [createArrowHeadRing(p2, bottomDir, arrowHeadLength, arrowHeadWidth)]];
3857
- return {
3858
- type: "FeatureCollection",
3859
- features: [{
3860
- type: "Feature",
3861
- properties: {},
3862
- geometry: {
3863
- type: "MultiLineString",
3864
- coordinates: lineSegments
3865
- }
3866
- }, {
3867
- type: "Feature",
3868
- properties: { fill: true },
3869
- geometry: {
3870
- type: "MultiPolygon",
3871
- coordinates: polygonRings
3872
- }
3873
- }]
3874
- };
3875
- }
3876
- function createZigzagRear(rearTop, rearBottom, inwardDir, openingLength, amplitudeRatio, spacingRatio) {
3877
- const axis = vecSub(rearBottom, rearTop);
3878
- const axisLength = vecMag(axis);
3879
- if (axisLength < 1e-6) return [rearTop, rearBottom];
3880
- const axisDir = vecNorm(axis);
3881
- const amplitude = Math.max(openingLength * amplitudeRatio, EPSILON);
3882
- const spacing = Math.max(openingLength * spacingRatio, EPSILON * 10);
3883
- const teeth = Math.max(2, Math.round(axisLength / spacing));
3884
- const step = axisLength / teeth;
3885
- const points = [rearTop];
3886
- for (let i = 0; i < teeth; i += 1) {
3887
- const mid = vecAdd(rearTop, vecAdd(vecScale(axisDir, step * (i + .5)), vecScale(inwardDir, amplitude)));
3888
- const base = vecAdd(rearTop, vecScale(axisDir, step * (i + 1)));
3889
- points.push(mid, base);
3890
- }
3891
- return points;
3892
- }
3893
- const OBSTACLE_BYPASS_DIFFICULT = defineControlMeasure({
3894
- metadata: OBSTACLE_BYPASS_DIFFICULT_METADATA,
3895
- generator: createObstacleBypassDifficult,
3896
- defaultOptions: DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS,
3897
- rule: point12DrawRule
3898
- });
3899
- //#endregion
3900
- //#region src/generators/cm27-protection-areas/obstacleBypassEasy.ts
3901
- const DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS = {
3902
- arrowHeadLengthRatio: .2,
3903
- arrowHeadWidthRatio: .2,
3904
- defaultLengthRatio: 1.5
3905
- };
3906
- const OBSTACLE_BYPASS_EASY_METADATA = {
3907
- id: "obstacle-bypass-easy",
3908
- name: "Obstacle Bypass Easy",
3909
- description: "Obstacle bypass (easy) with parallel rails and two arrowheads.",
3910
- entity: "Protection Areas",
3911
- entityType: "Obstacle Bypass",
3912
- entitySubtype: "Easy",
3913
- value: "270601",
3914
- minCoordinates: 2,
3915
- geometry: "line",
3916
- geometryTypes: ["MultiLineString", "MultiPolygon"],
3917
- drawRule: "Point12",
3918
- params: OBSTACLE_BYPASS_PARAMS
3919
- };
3920
- /**
3921
- * Generates a GeoJSON FeatureCollection for an "Obstacle Bypass Easy" symbol.
3922
- *
3923
- * Input:
3924
- * - PT1 and PT2 define the tips of the two arrowheads (opening and symbol height)
3925
- * - PT3 defines signed symbol length along the perpendicular axis through midpoint(PT1, PT2)
3926
- *
3927
- * Geometry:
3928
- * - Rear line is parallel to opening(PT1-PT2) and has equal length
3929
- * - Two side rails run from rear endpoints to the arrowheads and are parallel to each other
3930
- * - Rear line is perpendicular to both side rails
3931
- * - Arrow heads are filled triangle polygons at PT1 and PT2
3932
- */
3933
- function createObstacleBypassEasy(coordinates, options = {}) {
3934
- const c1 = coordinates[0];
3935
- const c2 = coordinates[1];
3936
- const c3 = coordinates[2];
3937
- const arrowHeadLengthRatio = options.arrowHeadLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS.arrowHeadLengthRatio;
3938
- const arrowHeadWidthRatio = options.arrowHeadWidthRatio ?? DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS.arrowHeadWidthRatio;
3939
- const defaultLengthRatio = options.defaultLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS.defaultLengthRatio;
3940
- const p1 = project(c1[0], c1[1]);
3941
- const p2 = project(c2[0], c2[1]);
3942
- const openingVec = vecSub(p2, p1);
3943
- const openingLength = vecMag(openingVec);
3944
- if (openingLength < 1e-6) return {
3945
- type: "FeatureCollection",
3946
- features: []
3947
- };
3948
- const openingDir = vecNorm(openingVec);
3949
- const railDir = [openingDir[1], -openingDir[0]];
3950
- const openingMid = vecScale(vecAdd(p1, p2), .5);
3951
- let signedLength;
3952
- if (c3) {
3953
- const rawSignedLength = vecDot(vecSub(project(c3[0], c3[1]), openingMid), railDir);
3954
- if (Math.abs(rawSignedLength) < 1e-6) signedLength = (rawSignedLength >= 0 ? 1 : -1) * openingLength * defaultLengthRatio;
3955
- else signedLength = rawSignedLength;
3956
- } else signedLength = openingLength * defaultLengthRatio;
3957
- if (Math.abs(signedLength) < 1e-6) return {
3958
- type: "FeatureCollection",
3959
- features: []
3960
- };
3961
- const rearMid = vecAdd(openingMid, vecScale(railDir, signedLength));
3962
- const halfOpening = vecScale(openingDir, openingLength / 2);
3963
- const rearTop = vecSub(rearMid, halfOpening);
3964
- const rearBottom = vecAdd(rearMid, halfOpening);
3965
- const arrowHeadLength = Math.max(openingLength * arrowHeadLengthRatio, EPSILON);
3966
- const arrowHeadWidth = Math.max(openingLength * arrowHeadWidthRatio, EPSILON);
3967
- const topDir = vecNorm(vecSub(p1, rearTop));
3968
- const topHeadBase = vecSub(p1, vecScale(topDir, arrowHeadLength));
3969
- const bottomDir = vecNorm(vecSub(p2, rearBottom));
3970
- const bottomHeadBase = vecSub(p2, vecScale(bottomDir, arrowHeadLength));
3971
- const lineSegments = [
3972
- [unproject(rearTop[0], rearTop[1]), unproject(rearBottom[0], rearBottom[1])],
3973
- [unproject(rearTop[0], rearTop[1]), unproject(topHeadBase[0], topHeadBase[1])],
3974
- [unproject(rearBottom[0], rearBottom[1]), unproject(bottomHeadBase[0], bottomHeadBase[1])]
3975
- ];
3976
- const polygonRings = [[createArrowHeadRing(p1, topDir, arrowHeadLength, arrowHeadWidth)], [createArrowHeadRing(p2, bottomDir, arrowHeadLength, arrowHeadWidth)]];
3977
- return {
3978
- type: "FeatureCollection",
3979
- features: [{
3980
- type: "Feature",
3981
- properties: {},
3982
- geometry: {
3983
- type: "MultiLineString",
3984
- coordinates: lineSegments
3985
- }
3986
- }, {
3987
- type: "Feature",
3988
- properties: { fill: true },
3989
- geometry: {
3990
- type: "MultiPolygon",
3991
- coordinates: polygonRings
3992
- }
3993
- }]
3994
- };
3995
- }
3996
- const OBSTACLE_BYPASS_EASY = defineControlMeasure({
3997
- metadata: OBSTACLE_BYPASS_EASY_METADATA,
3998
- generator: createObstacleBypassEasy,
3999
- defaultOptions: DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS,
4000
- rule: point12DrawRule
4001
- });
4002
- //#endregion
4003
- //#region src/generators/cm27-protection-areas/obstacleBypassImpossible.ts
4004
- const DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS = {
4005
- arrowHeadLengthRatio: .2,
4006
- arrowHeadWidthRatio: .2,
4007
- defaultLengthRatio: 1.5,
4008
- rearBarLengthRatio: .12,
4009
- rearGapRatio: .08
4010
- };
4011
- const OBSTACLE_BYPASS_IMPOSSIBLE_METADATA = {
4012
- id: "obstacle-bypass-impossible",
4013
- name: "Obstacle Bypass Impossible",
4014
- description: "Obstacle bypass (impossible) with rear barrier marks and arrowheads.",
4015
- entity: "Protection Areas",
4016
- entityType: "Obstacle Bypass",
4017
- entitySubtype: "Impossible",
4018
- value: "270603",
4019
- minCoordinates: 2,
4020
- geometry: "line",
4021
- geometryTypes: ["MultiLineString", "MultiPolygon"],
4022
- drawRule: "Point12",
4023
- params: [
4024
- ...OBSTACLE_BYPASS_PARAMS,
4025
- {
4026
- key: "rearBarLengthRatio",
4027
- label: "Rear bar length",
4028
- description: "Length of each rear barrier bar as a ratio of the opening width.",
4029
- type: "number",
4030
- min: .01,
4031
- max: 1,
4032
- step: .01
4033
- },
4034
- {
4035
- key: "rearGapRatio",
4036
- label: "Rear gap",
4037
- description: "Gap between the two rear bars as a ratio of the opening width.",
4038
- type: "number",
4039
- min: 0,
4040
- max: 1,
4041
- step: .01
4042
- }
4043
- ]
4044
- };
4045
- /**
4046
- * Generates a GeoJSON FeatureCollection for an "Obstacle Bypass Impossible" symbol.
4047
- *
4048
- * Input:
4049
- * - PT1 and PT2 define the tips of the two arrowheads (opening and symbol height)
4050
- * - PT3 defines signed symbol length along the perpendicular axis through midpoint(PT1, PT2)
4051
- *
4052
- * Geometry:
4053
- * - Same base as obstacle bypass easy
4054
- * - Rear line has two short perpendicular bars on the rear side
4055
- */
4056
- function createObstacleBypassImpossible(coordinates, options = {}) {
4057
- const c1 = coordinates[0];
4058
- const c2 = coordinates[1];
4059
- const c3 = coordinates[2];
4060
- const arrowHeadLengthRatio = options.arrowHeadLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS.arrowHeadLengthRatio;
4061
- const arrowHeadWidthRatio = options.arrowHeadWidthRatio ?? DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS.arrowHeadWidthRatio;
4062
- const defaultLengthRatio = options.defaultLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS.defaultLengthRatio;
4063
- const rearBarLengthRatio = options.rearBarLengthRatio ?? DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS.rearBarLengthRatio;
4064
- const rearGapRatio = options.rearGapRatio ?? DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS.rearGapRatio;
4065
- const p1 = project(c1[0], c1[1]);
4066
- const p2 = project(c2[0], c2[1]);
4067
- const openingVec = vecSub(p2, p1);
4068
- const openingLength = vecMag(openingVec);
4069
- if (openingLength < 1e-6) return {
4070
- type: "FeatureCollection",
4071
- features: []
4072
- };
4073
- const openingDir = vecNorm(openingVec);
4074
- const railDir = [openingDir[1], -openingDir[0]];
4075
- const openingMid = vecScale(vecAdd(p1, p2), .5);
4076
- let signedLength;
4077
- if (c3) {
4078
- const rawSignedLength = vecDot(vecSub(project(c3[0], c3[1]), openingMid), railDir);
4079
- if (Math.abs(rawSignedLength) < 1e-6) signedLength = (rawSignedLength >= 0 ? 1 : -1) * openingLength * defaultLengthRatio;
4080
- else signedLength = rawSignedLength;
4081
- } else signedLength = openingLength * defaultLengthRatio;
4082
- if (Math.abs(signedLength) < 1e-6) return {
4083
- type: "FeatureCollection",
4084
- features: []
4085
- };
4086
- const rearMid = vecAdd(openingMid, vecScale(railDir, signedLength));
4087
- const halfOpening = vecScale(openingDir, openingLength / 2);
4088
- const rearTop = vecSub(rearMid, halfOpening);
4089
- const rearBottom = vecAdd(rearMid, halfOpening);
4090
- const arrowHeadLength = Math.max(openingLength * arrowHeadLengthRatio, EPSILON);
4091
- const arrowHeadWidth = Math.max(openingLength * arrowHeadWidthRatio, EPSILON);
4092
- const topDir = vecNorm(vecSub(p1, rearTop));
4093
- const topHeadBase = vecSub(p1, vecScale(topDir, arrowHeadLength));
4094
- const bottomDir = vecNorm(vecSub(p2, rearBottom));
4095
- const bottomHeadBase = vecSub(p2, vecScale(bottomDir, arrowHeadLength));
4096
- const barLength = Math.max(openingLength * rearBarLengthRatio, EPSILON);
4097
- const gapHalf = Math.max(openingLength * rearGapRatio * .5, EPSILON * 5);
4098
- const barHalf = barLength * .5;
4099
- const upperBarAnchor = vecAdd(rearMid, vecScale(openingDir, -gapHalf));
4100
- const lowerBarAnchor = vecAdd(rearMid, vecScale(openingDir, gapHalf));
4101
- const upperGapEnd = upperBarAnchor;
4102
- const lowerGapEnd = lowerBarAnchor;
4103
- const lineSegments = [
4104
- [unproject(rearTop[0], rearTop[1]), unproject(upperGapEnd[0], upperGapEnd[1])],
4105
- [unproject(lowerGapEnd[0], lowerGapEnd[1]), unproject(rearBottom[0], rearBottom[1])],
4106
- [unproject(rearTop[0], rearTop[1]), unproject(topHeadBase[0], topHeadBase[1])],
4107
- [unproject(rearBottom[0], rearBottom[1]), unproject(bottomHeadBase[0], bottomHeadBase[1])],
4108
- [unproject(upperBarAnchor[0] - railDir[0] * barHalf, upperBarAnchor[1] - railDir[1] * barHalf), unproject(upperBarAnchor[0] + railDir[0] * barHalf, upperBarAnchor[1] + railDir[1] * barHalf)],
4109
- [unproject(lowerBarAnchor[0] - railDir[0] * barHalf, lowerBarAnchor[1] - railDir[1] * barHalf), unproject(lowerBarAnchor[0] + railDir[0] * barHalf, lowerBarAnchor[1] + railDir[1] * barHalf)]
4110
- ];
4111
- const polygonRings = [[createArrowHeadRing(p1, topDir, arrowHeadLength, arrowHeadWidth)], [createArrowHeadRing(p2, bottomDir, arrowHeadLength, arrowHeadWidth)]];
4112
- return {
4113
- type: "FeatureCollection",
4114
- features: [{
4115
- type: "Feature",
4116
- properties: {},
4117
- geometry: {
4118
- type: "MultiLineString",
4119
- coordinates: lineSegments
4120
- }
4121
- }, {
4122
- type: "Feature",
4123
- properties: { fill: true },
4124
- geometry: {
4125
- type: "MultiPolygon",
4126
- coordinates: polygonRings
4127
- }
4128
- }]
4129
- };
4130
- }
4131
- const OBSTACLE_BYPASS_IMPOSSIBLE = defineControlMeasure({
4132
- metadata: OBSTACLE_BYPASS_IMPOSSIBLE_METADATA,
4133
- generator: createObstacleBypassImpossible,
4134
- defaultOptions: DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS,
4135
- rule: point12DrawRule
4136
- });
4137
- //#endregion
4138
- //#region src/generators/cm14-maneuver-lines/principalDirectionOfFire.ts
4139
- const DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS = {
4140
- hSize: .07,
4141
- hWidth: .67
4142
- };
4143
- const PRINCIPAL_DIRECTION_OF_FIRE_METADATA = {
4144
- id: "principal-direction-of-fire",
4145
- name: "Principal Direction of Fire",
4146
- description: "A specific direction or sector toward which a weapon or unit directs fire for a tactical purpose.",
4147
- entity: "Maneuver Lines",
4148
- entityType: "Field of Fire",
4149
- entitySubtype: "Principal Direction of Fire",
4150
- value: "140503",
4151
- minCoordinates: 2,
4152
- maxCoordinates: 3,
4153
- geometry: "line",
4154
- geometryTypes: ["MultiLineString"],
4155
- drawRule: "Line3",
4156
- params: [{
4157
- key: "hSize",
4158
- label: "Head size",
4159
- description: "Arrowhead length as a fraction of each arm's length",
4160
- type: "number",
4161
- min: .01,
4162
- max: .5,
4163
- step: .01
4164
- }, {
4165
- key: "hWidth",
4166
- label: "Head width",
4167
- description: "Arrowhead wing spread relative to the arrowhead length",
4168
- type: "number",
4169
- min: .05,
4170
- max: 2,
4171
- step: .01
4172
- }]
4173
- };
4174
- /**
4175
- * Generates a GeoJSON FeatureCollection for a PRINCIPAL DIRECTION OF FIRE
4176
- * tactical symbol: a "V" of arms from the vertex out to the (independent) tips,
4177
- * each ending in an open arrowhead.
4178
- *
4179
- * Renders one arm per tip, so two points (vertex + one tip) draws the single
4180
- * arm being placed — the live feedback after PT 1 — and three points draws the
4181
- * full V. Points beyond the third are ignored (input contract, ADR-0014).
4182
- *
4183
- * @param coordinates - [PT 1 (vertex), PT 2 (tip), PT 3 (tip)]
4184
- * @param options - Arrowhead sizing parameters
4185
- */
4186
- function createPrincipalDirectionOfFire(coordinates, options = {}) {
4187
- const { hSize, hWidth } = {
4188
- ...DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS,
4189
- ...options
4190
- };
4191
- const v = buildFieldOfFireV(coordinates, Math.max(0, hSize), Math.max(0, hWidth));
4192
- if (!v) return {
4193
- type: "FeatureCollection",
4194
- features: []
4195
- };
4196
- return {
4197
- type: "FeatureCollection",
4198
- features: [{
4199
- type: "Feature",
4200
- properties: {},
4201
- geometry: {
4202
- type: "MultiLineString",
4203
- coordinates: v.lines.map((line) => line.map((c) => unproject(c[0], c[1])))
4204
- }
4205
- }]
4206
- };
4207
- }
4208
- const PRINCIPAL_DIRECTION_OF_FIRE = defineControlMeasure({
4209
- metadata: PRINCIPAL_DIRECTION_OF_FIRE_METADATA,
4210
- generator: createPrincipalDirectionOfFire,
4211
- defaultOptions: DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS,
4212
- rule: line3DrawRule
4213
- });
4214
- //#endregion
4215
- //#region src/generators/cm15-maneuver-areas/searchArea.ts
4216
- const DEFAULT_HEAD_SIZE_RATIO = .12;
4217
- const DEFAULT_HEAD_WIDTH_MULTIPLIER = .5;
4218
- const DEFAULT_ZIGZAG_START = .55;
4219
- const DEFAULT_ZIGZAG_END = .45;
4220
- const DEFAULT_ZIGZAG_WIDTH = .07;
4221
- /**
4222
- * Default options for the tactical arrow.
4223
- */
4224
- const DEFAULT_TACTICAL_ARROW_OPTIONS = {
4225
- headSizeRatio: DEFAULT_HEAD_SIZE_RATIO,
4226
- headWidthMultiplier: DEFAULT_HEAD_WIDTH_MULTIPLIER,
4227
- zigzagStart: DEFAULT_ZIGZAG_START,
4228
- zigzagEnd: DEFAULT_ZIGZAG_END,
4229
- zigzagWidth: DEFAULT_ZIGZAG_WIDTH
4230
- };
4231
- const SEARCH_AREA_METADATA = {
4232
- id: "search-area",
4233
- name: "Search Area",
4234
- description: "Search area graphic with paired lightning-style arrows.",
4235
- entity: "Maneuver Areas",
4236
- entityType: "Search Area",
4237
- value: "152200",
4238
- minCoordinates: 3,
4239
- maxCoordinates: 3,
4240
- geometry: "area",
4241
- geometryTypes: ["MultiLineString", "MultiPolygon"],
4242
- drawRule: "Area21",
4243
- params: [
4244
- {
4245
- key: "headSizeRatio",
4246
- label: "Head size",
4247
- description: "Arrowhead length as a ratio of the total leg length.",
4248
- type: "number",
4249
- min: 0,
4250
- max: .5,
4251
- step: .01
4252
- },
4253
- {
4254
- key: "headWidthMultiplier",
4255
- label: "Head width",
4256
- description: "Arrowhead wing width as a multiple of the head length.",
4257
- type: "number",
4258
- min: .05,
4259
- max: 2,
4260
- step: .1
4261
- },
4262
- {
4263
- key: "zigzagStart",
4264
- label: "Zigzag start",
4265
- description: "Point along the leg where the zigzag begins (0-1).",
4266
- type: "number",
4267
- min: 0,
4268
- max: 1,
4269
- step: .05
4270
- },
4271
- {
4272
- key: "zigzagEnd",
4273
- label: "Zigzag end",
4274
- description: "Point along the leg where the zigzag returns (0-1).",
4275
- type: "number",
4276
- min: 0,
4277
- max: 1,
4278
- step: .05
4279
- },
4280
- {
4281
- key: "zigzagWidth",
4282
- label: "Zigzag width",
4283
- description: "Perpendicular kink offset as a ratio of leg length.",
4284
- type: "number",
4285
- min: 0,
4286
- max: .3,
4287
- step: .01
4288
- }
4289
- ]
4290
- };
4291
- /**
4292
- * Generates a GeoJSON FeatureCollection of a tactical "lightning" arrow.
4293
- * Handles internal projection to maintain correct perpendicularity.
4294
- *
4295
- * @param coordinates - [Apex Point (PT1), Top Tail (PT2), Bottom Tail (PT3)]
4296
- * @param options - Geometric configuration parameters
4297
- * @returns A FeatureCollection containing a MultiLineString for bolts and a MultiPolygon for heads
4298
- */
4299
- function createSearchArea(coordinates, options = {}) {
4300
- const { headSizeRatio = DEFAULT_HEAD_SIZE_RATIO, headWidthMultiplier = DEFAULT_HEAD_WIDTH_MULTIPLIER, zigzagStart = DEFAULT_ZIGZAG_START, zigzagEnd = DEFAULT_ZIGZAG_END, zigzagWidth = DEFAULT_ZIGZAG_WIDTH } = options;
4301
- const safeHeadSizeRatio = Math.max(0, headSizeRatio);
4302
- const safeHeadWidthMultiplier = Math.max(0, headWidthMultiplier);
4303
- const safeZigzagStart = clamp01(zigzagStart);
4304
- const safeZigzagEnd = clamp01(zigzagEnd);
4305
- const safeZigzagWidth = Math.max(0, zigzagWidth);
4306
- const [c0, c1, c2] = coordinates;
4307
- const [p1, p2, p3] = [
4308
- project(c0[0], c0[1]),
4309
- project(c1[0], c1[1]),
4310
- project(c2[0], c2[1])
4311
- ];
4312
- const shaftSegments = [];
4313
- const headPolygons = [];
4314
- const addLeg = (origin, tip, opposite) => {
4315
- const v = vecSub(tip, origin);
4316
- const totalLen = vecMag(v);
4317
- if (totalLen < 1e-6) return;
4318
- const u = vecNorm(v);
4319
- let n = [-u[1], u[0]];
4320
- const toOpp = vecSub(opposite, origin);
4321
- if (vecDot(n, toOpp) > 0) n = vecScale(n, -1);
4322
- const offset = totalLen * safeZigzagWidth;
4323
- const ptA = vecAdd(vecAdd(origin, vecScale(v, safeZigzagStart)), vecScale(n, offset));
4324
- const ptB = vecAdd(vecAdd(origin, vecScale(v, safeZigzagEnd)), vecScale(n, -offset));
4325
- const fv = vecSub(tip, ptB);
4326
- const fLen = vecMag(fv);
4327
- let fu = vecNorm(fv);
4328
- if (fLen < 1e-6) fu = u;
4329
- const headLen = totalLen * safeHeadSizeRatio;
4330
- const pBase = vecSub(tip, vecScale(fu, headLen));
4331
- const wingWidth = headLen * safeHeadWidthMultiplier;
4332
- const perp = [-fu[1], fu[0]];
4333
- const pWing1 = vecAdd(pBase, vecScale(perp, wingWidth));
4334
- const pWing2 = vecSub(pBase, vecScale(perp, wingWidth));
4335
- shaftSegments.push([
4336
- origin,
4337
- ptA,
4338
- ptB,
4339
- pBase
4340
- ].map((p) => unproject(p[0], p[1])));
4341
- headPolygons.push([[
4342
- tip,
4343
- pWing1,
4344
- pWing2,
4345
- tip
4346
- ].map((p) => unproject(p[0], p[1]))]);
4347
- };
4348
- addLeg(p1, p2, p3);
4349
- addLeg(p1, p3, p2);
4350
- const features = [];
4351
- if (shaftSegments.length > 0) features.push({
4352
- type: "Feature",
4353
- properties: { part: "shaft" },
4354
- geometry: {
4355
- type: "MultiLineString",
4356
- coordinates: shaftSegments
4357
- }
4358
- });
4359
- if (headPolygons.length > 0) features.push({
4360
- type: "Feature",
4361
- properties: {
4362
- part: "head",
4363
- fill: true
4364
- },
4365
- geometry: {
4366
- type: "MultiPolygon",
4367
- coordinates: headPolygons
4368
- }
4369
- });
4370
- return {
4371
- type: "FeatureCollection",
4372
- features
4373
- };
4374
- }
4375
- const SEARCH_AREA = defineControlMeasure({
4376
- metadata: SEARCH_AREA_METADATA,
4377
- generator: (controlPoints, options) => createSearchArea(asThreePoints(controlPoints), options),
4378
- defaultOptions: DEFAULT_TACTICAL_ARROW_OPTIONS,
4379
- rule: searchAreaDrawRule
4380
- });
4381
- //#endregion
4382
- //#region src/generators/cm15-maneuver-areas/supportByFire.ts
4383
- const DEFAULT_SUPPORT_BY_FIRE_OPTIONS = {
4384
- arrowTipWidthRatio: .3,
4385
- backLineLengthRatio: .3,
4386
- backLineAngle: 45
4387
- };
4388
- const SUPPORT_BY_FIRE_METADATA = {
4389
- id: "support-by-fire",
4390
- name: "Support By Fire",
4391
- description: "Support by fire graphic with paired arrows.",
4392
- entity: "Maneuver Areas",
4393
- entityType: "Support by Fire",
4394
- value: "152100",
4395
- minCoordinates: 4,
4396
- geometry: "line",
4397
- geometryTypes: ["LineString"],
4398
- drawRule: "Area8",
4399
- params: FIRE_ARROW_PARAMS
4400
- };
4401
- /**
4402
- * Generates a GeoJSON FeatureCollection for a Support By Fire tactical graphic.
4403
- *
4404
- * Input:
4405
- * - PT1: Left Base
4406
- * - PT2: Right Base
4407
- * - PT3: Left Tip
4408
- * - PT4: Right Tip
4409
- */
4410
- function createSupportByFire(coordinates, options = {}) {
4411
- const featureProps = {};
4412
- const arrowTipWidthRatio = options.arrowTipWidthRatio ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.arrowTipWidthRatio;
4413
- const backLineLengthRatio = options.backLineLengthRatio ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.backLineLengthRatio;
4414
- const backLineAngle = options.backLineAngle ?? DEFAULT_SUPPORT_BY_FIRE_OPTIONS.backLineAngle;
4415
- const c1 = coordinates[0];
4416
- const c2 = coordinates[1];
4417
- const c3 = coordinates[2];
4418
- const c4 = coordinates[3];
4419
- const p1 = project(c1[0], c1[1]);
4420
- const p2 = project(c2[0], c2[1]);
4421
- const p3 = project(c3[0], c3[1]);
4422
- const p4 = project(c4[0], c4[1]);
4423
- const vBase = vecSub(p2, p1);
4424
- const lenBase = vecMag(vBase);
4425
- const dirBase = vecNorm(vBase);
4426
- if (lenBase < 1e-6) return {
4427
- type: "FeatureCollection",
4428
- features: []
4429
- };
4430
- const angleRad = backLineAngle * Math.PI / 180;
4431
- const baseAngle = -Math.PI / 2;
4432
- const angleLeft = baseAngle - angleRad;
4433
- const dirBackLeft = [dirBase[0] * Math.cos(angleLeft) - dirBase[1] * Math.sin(angleLeft), dirBase[0] * Math.sin(angleLeft) + dirBase[1] * Math.cos(angleLeft)];
4434
- const angleRight = baseAngle + angleRad;
4435
- const dirBackRight = [dirBase[0] * Math.cos(angleRight) - dirBase[1] * Math.sin(angleRight), dirBase[0] * Math.sin(angleRight) + dirBase[1] * Math.cos(angleRight)];
4436
- const backLen = lenBase * backLineLengthRatio;
4437
- const p1Back = vecAdd(p1, vecScale(dirBackLeft, backLen));
4438
- const p2Back = vecAdd(p2, vecScale(dirBackRight, backLen));
4439
- const vLeft = vecSub(p3, p1);
4440
- const lenLeft = vecMag(vLeft);
4441
- const dirLeft = vecNorm(vLeft);
4442
- const vRight = vecSub(p4, p2);
4443
- const lenRight = vecMag(vRight);
4444
- const dirRight = vecNorm(vRight);
4445
- const commonHeadWidth = (lenLeft + lenRight) / 2 * arrowTipWidthRatio;
4446
- const commonHeadLen = commonHeadWidth;
4447
- const leftHeadFeatures = createArrowHead(p3, dirLeft, commonHeadLen, commonHeadWidth);
4448
- const rightHeadFeatures = createArrowHead(p4, dirRight, commonHeadLen, commonHeadWidth);
4449
- return {
4450
- type: "FeatureCollection",
4451
- features: [
4452
- {
4453
- type: "Feature",
4454
- properties: featureProps,
4455
- geometry: {
4456
- type: "LineString",
4457
- coordinates: [unproject(p1[0], p1[1]), unproject(p1Back[0], p1Back[1])]
4458
- }
4459
- },
4460
- {
4461
- type: "Feature",
4462
- properties: featureProps,
4463
- geometry: {
4464
- type: "LineString",
4465
- coordinates: [unproject(p2[0], p2[1]), unproject(p2Back[0], p2Back[1])]
4466
- }
4467
- },
4468
- {
4469
- type: "Feature",
4470
- properties: featureProps,
4471
- geometry: {
4472
- type: "LineString",
4473
- coordinates: [unproject(p1[0], p1[1]), unproject(p3[0], p3[1])]
4474
- }
4475
- },
4476
- {
4477
- type: "Feature",
4478
- properties: featureProps,
4479
- geometry: {
4480
- type: "LineString",
4481
- coordinates: [unproject(p2[0], p2[1]), unproject(p4[0], p4[1])]
4482
- }
4483
- },
4484
- {
4485
- type: "Feature",
4486
- properties: featureProps,
4487
- geometry: {
4488
- type: "LineString",
4489
- coordinates: [unproject(p1[0], p1[1]), unproject(p2[0], p2[1])]
4490
- }
4491
- },
4492
- ...leftHeadFeatures.map((f) => ({
4493
- ...f,
4494
- properties: featureProps
4495
- })),
4496
- ...rightHeadFeatures.map((f) => ({
4497
- ...f,
4498
- properties: featureProps
4499
- }))
4500
- ]
4501
- };
4502
- }
4503
- function createArrowHead(tip, dir, length, width) {
4504
- const norm = [-dir[1], dir[0]];
4505
- const base = vecSub(tip, vecScale(dir, length));
4506
- const left = vecAdd(base, vecScale(norm, width / 2));
4507
- const right = vecSub(base, vecScale(norm, width / 2));
4508
- return [{
4509
- type: "Feature",
4510
- geometry: {
4511
- type: "LineString",
4512
- coordinates: [
4513
- unproject(left[0], left[1]),
4514
- unproject(tip[0], tip[1]),
4515
- unproject(right[0], right[1])
4516
- ]
4517
- },
4518
- properties: {}
4519
- }];
4520
- }
4521
- const SUPPORT_BY_FIRE = defineControlMeasure({
4522
- metadata: SUPPORT_BY_FIRE_METADATA,
4523
- generator: createSupportByFire,
4524
- defaultOptions: DEFAULT_SUPPORT_BY_FIRE_OPTIONS,
4525
- rule: supportByFireDrawRule
4526
- });
4527
- //#endregion
4528
- //#region src/generators/cm15-maneuver-areas/supportingAttack.ts
4529
- const DEFAULT_SUPPORTING_ATTACK_OPTIONS = {
4530
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO,
4531
- roundedBends: false,
4532
- bendSegments: 5
4533
- };
4534
- const SUPPORTING_ATTACK_METADATA = {
4535
- id: "supporting-attack",
4536
- name: "Supporting Attack",
4537
- description: "Secondary attack that supports the main effort.",
4538
- entity: "Maneuver Areas",
4539
- entityType: "Axis of Advance",
4540
- entitySubtype: "Supporting Attack",
4541
- value: "151404",
4542
- minCoordinates: 3,
4543
- geometry: "line",
4544
- geometryTypes: ["LineString"],
4545
- drawRule: "Axis1",
4546
- params: ATTACK_SHAFT_PARAMS
4547
- };
4548
- /**
4549
- * Generates a GeoJSON FeatureCollection for a Supporting Attack tactical symbol.
4550
- *
4551
- * The symbol differs from Main Attack in that it is a single LineString
4552
- * outlining the arrow shape, rather than a filled Polygon header + Shaft lines.
4553
- *
4554
- * @param coordinates - An array of GeoJSON Positions.
4555
- * @param options - Configuration options.
4556
- * @returns A GeoJSON FeatureCollection<LineString>.
4557
- */
4558
- function createSupportingAttack(coordinates, options = {}) {
4559
- const { geometry } = processAttackGeometry(coordinates, options);
4560
- if (!geometry) return {
4561
- type: "FeatureCollection",
4562
- features: []
4563
- };
4564
- return {
4565
- type: "FeatureCollection",
4566
- features: [{
4567
- type: "Feature",
4568
- properties: {},
4569
- geometry: {
4570
- type: "LineString",
4571
- coordinates: [
4572
- ...geometry.shaftLeft,
4573
- geometry.headRing[0],
4574
- geometry.headRing[1],
4575
- geometry.headRing[2],
4576
- ...[...geometry.shaftRight].reverse()
4577
- ].map((p) => unproject(p[0], p[1]))
4578
- }
4579
- }]
4580
- };
4581
- }
4582
- const SUPPORTING_ATTACK = defineControlMeasure({
4583
- metadata: SUPPORTING_ATTACK_METADATA,
4584
- generator: createSupportingAttack,
4585
- defaultOptions: DEFAULT_SUPPORTING_ATTACK_OPTIONS,
4586
- rule: axis1DrawRule
4587
- });
4588
- //#endregion
4589
- //#region src/generators/cm27-protection-areas/turn.ts
4590
- const DEFAULT_TURN_OPTIONS = {
4591
- arrowHeadLengthRatio: .16,
4592
- arrowHeadWidthRatio: .16,
4593
- arcSegments: 64
4594
- };
4595
- const TURN_METADATA = {
4596
- id: "turn",
4597
- name: "Turn",
4598
- description: "An obstacle effect that integrates fire planning and obstacle effort to drive an enemy formation from one avenue of approach to an adjacent avenue of approach or into an engagement area. The rear of the symbol identifies the enemy's location and the arrow points in the direction the obstacle should force the enemy to turn.",
4599
- entity: "Protection Areas",
4600
- entityType: "Obstacle Effects",
4601
- entitySubtype: "Turn",
4602
- value: "270504",
4603
- minCoordinates: 2,
4604
- geometry: "line",
4605
- geometryTypes: ["MultiLineString", "MultiPolygon"],
4606
- drawRule: "Line10",
4607
- params: [
4608
- {
4609
- key: "arrowHeadLengthRatio",
4610
- label: "Arrowhead length",
4611
- description: "Arrowhead length as a ratio of the rear-to-tip distance.",
4612
- type: "number",
4613
- min: .01,
4614
- max: .5,
4615
- step: .01
4616
- },
4617
- {
4618
- key: "arrowHeadWidthRatio",
4619
- label: "Arrowhead width",
4620
- description: "Arrowhead base width as a ratio of the rear-to-tip distance.",
4621
- type: "number",
4622
- min: .01,
4623
- max: .5,
4624
- step: .01
4625
- },
4626
- {
4627
- key: "arcSegments",
4628
- label: "Arc segments",
4629
- description: "Number of line segments approximating the quarter-circle shaft.",
4630
- type: "number",
4631
- min: 4,
4632
- max: 128,
4633
- step: 1
4634
- }
4635
- ]
4636
- };
4637
- /**
4638
- * Generates a quarter-circle shaft from PT2 (rear) to the arrowhead base.
4639
- * The arrowhead continues tangent to the arc and places its tip at PT1.
4640
- * PT3 selects which side of the PT2 -> PT1 chord contains the arc.
4641
- */
4642
- function createTurn(coordinates, options = {}) {
4643
- const tip = project(coordinates[0][0], coordinates[0][1]);
4644
- const rear = project(coordinates[1][0], coordinates[1][1]);
4645
- const side = quarterArcSide(rear, tip, coordinates[2] ? project(coordinates[2][0], coordinates[2][1]) : void 0);
4646
- const rearToTip = vecSub(tip, rear);
4647
- const totalLength = vecMag(rearToTip);
4648
- if (totalLength < 1e-6) return {
4649
- type: "FeatureCollection",
4650
- features: []
4651
- };
4652
- const requestedHeadLength = totalLength * (options.arrowHeadLengthRatio ?? DEFAULT_TURN_OPTIONS.arrowHeadLengthRatio);
4653
- const tangentComponent = Math.min(Math.max(requestedHeadLength, EPSILON), totalLength * .95) / Math.SQRT2;
4654
- const arcChordLength = Math.sqrt(totalLength * totalLength - tangentComponent * tangentComponent) - tangentComponent;
4655
- const chordOffset = Math.atan2(tangentComponent, arcChordLength + tangentComponent);
4656
- const headBase = vecAdd(rear, vecScale(rotate(vecNorm(rearToTip), side * chordOffset), arcChordLength));
4657
- const arc = createQuarterArc(rear, headBase, side);
4658
- if (!arc) return {
4659
- type: "FeatureCollection",
4660
- features: []
4661
- };
4662
- const segmentCount = Math.round(Math.min(128, Math.max(4, options.arcSegments ?? DEFAULT_TURN_OPTIONS.arcSegments)));
4663
- const arcCoordinates = [];
4664
- for (let i = 0; i <= segmentCount; i++) {
4665
- const angle = arc.startAngle + arc.sweepAngle * i / segmentCount;
4666
- arcCoordinates.push(unproject(arc.center[0] + arc.radius * Math.cos(angle), arc.center[1] + arc.radius * Math.sin(angle)));
4667
- }
4668
- const headWidth = totalLength * (options.arrowHeadWidthRatio ?? DEFAULT_TURN_OPTIONS.arrowHeadWidthRatio);
4669
- const arrowHead = createArrowHeadRing(tip, vecNorm(vecSub(tip, headBase)), vecMag(vecSub(tip, headBase)), headWidth);
4670
- return {
4671
- type: "FeatureCollection",
4672
- features: [{
4673
- type: "Feature",
4674
- properties: { part: "arc" },
4675
- geometry: {
4676
- type: "MultiLineString",
4677
- coordinates: [arcCoordinates]
4678
- }
4679
- }, {
4680
- type: "Feature",
4681
- properties: {
4682
- part: "arrowhead",
4683
- fill: true
4684
- },
4685
- geometry: {
4686
- type: "MultiPolygon",
4687
- coordinates: [[[...arrowHead]]]
4688
- }
4689
- }]
4690
- };
4691
- }
4692
- function rotate(vector, angle) {
4693
- const cos = Math.cos(angle);
4694
- const sin = Math.sin(angle);
4695
- return [vector[0] * cos - vector[1] * sin, vector[0] * sin + vector[1] * cos];
4696
- }
4697
- //#endregion
4698
- //#region src/registry.ts
4699
- /**
4700
- * The single source of truth for the catalog: one **control measure
4701
- * definition** record per measure, co-located with its generator and collected
4702
- * here. The `ControlMeasureId` union, `OptionsByKind`, the generator dispatch,
4703
- * the default options, and rule resolution are all *derived* from this map
4704
- * rather than maintained as parallel tables. Adding a measure is a one-file
4705
- * change plus one line here. See ADR-0013.
4706
- */
4707
- const DEFINITIONS = {
4708
- ambush: AMBUSH,
4709
- "principal-direction-of-fire": PRINCIPAL_DIRECTION_OF_FIRE,
4710
- "final-protective-fire-left": FINAL_PROTECTIVE_FIRE_LEFT,
4711
- "final-protective-fire-right": FINAL_PROTECTIVE_FIRE_RIGHT,
4712
- "search-area": SEARCH_AREA,
4713
- "main-attack": MAIN_ATTACK,
4714
- "supporting-attack": SUPPORTING_ATTACK,
4715
- "airborne-attack": AIRBORNE_ATTACK,
4716
- "attack-helicopter": ATTACK_HELICOPTER,
4717
- "support-by-fire": SUPPORT_BY_FIRE,
4718
- "attack-by-fire": ATTACK_BY_FIRE,
4719
- flot: FLOT,
4720
- "block-mission-task": BLOCK_MISSION_TASK,
4721
- breach: BREACH,
4722
- bypass: BYPASS,
4723
- canalize: CANALIZE,
4724
- clear: CLEAR,
4725
- delay: DELAY,
4726
- isolate: ISOLATE,
4727
- "antitank-ditch-under-construction": ANTITANK_DITCH_UNDER_CONSTRUCTION,
4728
- "antitank-ditch-completed": ANTITANK_DITCH_COMPLETED,
4729
- "antitank-wall": ANTITANK_WALL,
4730
- "fortified-line": FORTIFIED_LINE,
4731
- "fortified-area": FORTIFIED_AREA,
4732
- block: BLOCK,
4733
- disrupt: DISRUPT,
4734
- fix: FIX,
4735
- turn: defineControlMeasure({
4736
- metadata: TURN_METADATA,
4737
- generator: createTurn,
4738
- defaultOptions: DEFAULT_TURN_OPTIONS,
4739
- rule: turnDrawRule
4740
- }),
4741
- "obstacle-bypass-easy": OBSTACLE_BYPASS_EASY,
4742
- "obstacle-bypass-difficult": OBSTACLE_BYPASS_DIFFICULT,
4743
- "obstacle-bypass-impossible": OBSTACLE_BYPASS_IMPOSSIBLE
4744
- };
4745
- /** Insertion-ordered list of every control-measure id. */
4746
- const CONTROL_MEASURE_IDS = Object.keys(DEFINITIONS);
4747
- /**
4748
- * Merges a definition's resolved `rule` object back onto its metadata,
4749
- * preserving ADR-0005's `getControlMeasureMetadata` shape. Definitions whose
4750
- * `drawRule` id has no implementation leave `rule` undefined.
4751
- */
4752
- function withRule(definition) {
4753
- return definition.rule ? {
4754
- ...definition.metadata,
4755
- rule: definition.rule
4756
- } : definition.metadata;
4757
- }
4758
- const CONTROL_MEASURE_METADATA = Object.fromEntries(Object.entries(DEFINITIONS).map(([id, definition]) => [id, withRule(definition)]));
4759
- const CONTROL_MEASURE_METADATA_LIST = Object.values(CONTROL_MEASURE_METADATA);
4760
- const CONTROL_MEASURE_METADATA_BY_VALUE = CONTROL_MEASURE_METADATA_LIST.reduce((acc, metadata) => {
4761
- acc[metadata.value] = metadata;
4762
- return acc;
4763
- }, {});
4764
- function listControlMeasureMetadata() {
4765
- return CONTROL_MEASURE_METADATA_LIST;
4766
- }
4767
- function getControlMeasureMetadata(id) {
4768
- return CONTROL_MEASURE_METADATA[id];
4769
- }
4770
- function getControlMeasureMetadataByValue(value) {
4771
- return CONTROL_MEASURE_METADATA_BY_VALUE[value];
4772
- }
4773
- function getDefaultOptions(kind) {
4774
- return structuredClone(DEFINITIONS[kind].defaultOptions);
4775
- }
4776
- //#endregion
4777
- //#region src/style.ts
4778
- /**
4779
- * Ultimate fallback for the symbol color when no `color` or per-channel
4780
- * override is supplied at any layer. Keeps a zero-config render monocolor
4781
- * black and guarantees filled parts still render filled. See ADR-0011.
4782
- */
4783
- const DEFAULT_SYMBOL_COLOR = "#000000";
4784
- //#endregion
4785
- //#region src/styleResolver.ts
4786
- /**
4787
- * Three-layer style precedence for `renderControlMeasure`.
4788
- *
4789
- * Lower-priority → higher-priority: `graphicsStyle` (façade default) →
4790
- * `measureStyle` (`cm.style`) → `generatorStyle` (per-feature hint emitted
4791
- * by a generator). Merge is shallow and per-property; higher-priority layers
4792
- * win key by key. Explicit `null` / `undefined` in a higher-priority layer
4793
- * is treated as "no opinion" and does not clear a lower-priority key.
4794
- *
4795
- * Returns `undefined` when all three layers are absent so the renderer can
4796
- * omit `properties.style` entirely — keeps the no-style case byte-identical
4797
- * to a property-less feature.
4798
- *
4799
- * Pure function: inputs are never mutated; the returned object is always a
4800
- * fresh allocation when defined.
4801
- */
4802
- function resolveStyleHints(graphicsStyle, measureStyle, generatorStyle) {
4803
- if (!graphicsStyle && !measureStyle && !generatorStyle) return;
4804
- const merged = {};
4805
- applyLayer(merged, graphicsStyle);
4806
- applyLayer(merged, measureStyle);
4807
- applyLayer(merged, generatorStyle);
4808
- return merged;
4809
- }
4810
- /**
4811
- * Expand cascaded style hints into concrete per-feature paint, per the
4812
- * monocolor model (ADR-0011).
4813
- *
4814
- * A control measure is monocolor: the single `color` paints both stroke and
4815
- * fill. `strokeColor` / `fillColor` are optional per-channel overrides, and
4816
- * `DEFAULT_SYMBOL_COLOR` is the ultimate fallback so a zero-config render is
4817
- * still black and filled parts still render filled.
4818
- *
4819
- * `filled` is the generator's intrinsic, color-free marker that a part is a
4820
- * solid shape. Non-filled parts never carry a `fillColor` — filledness is
4821
- * intrinsic to the symbol and cannot be conjured by host styling. `color` is
4822
- * input-only and is stripped from the result.
4823
- *
4824
- * Pure: the input is not mutated; a fresh object is always returned.
4825
- */
4826
- function resolveSymbolColor(merged, filled) {
4827
- const { color, strokeColor, fillColor, ...rest } = merged ?? {};
4828
- const resolved = {
4829
- ...rest,
4830
- strokeColor: firstColor(strokeColor, color)
4831
- };
4832
- if (filled) resolved.fillColor = firstColor(fillColor, color);
4833
- return resolved;
4834
- }
4835
- /**
4836
- * First usable color among the candidates, else `DEFAULT_SYMBOL_COLOR`. An
4837
- * empty string is treated as "not set" (e.g. a cleared color input) rather
4838
- * than kept as an invalid paint, so the fallback chain still reaches black.
4839
- */
4840
- function firstColor(...candidates) {
4841
- for (const candidate of candidates) if (candidate !== void 0 && candidate !== "") return candidate;
4842
- return DEFAULT_SYMBOL_COLOR;
4843
- }
4844
- function applyLayer(target, layer) {
4845
- if (!layer) return;
4846
- for (const key of Object.keys(layer)) {
4847
- const value = layer[key];
4848
- if (value !== void 0 && value !== null) target[key] = value;
4849
- }
4850
- }
4851
- //#endregion
4852
- //#region src/renderControlMeasure.ts
4853
- /**
4854
- * Pure render: `ControlMeasure` → `ControlMeasureRender` (a
4855
- * `FeatureCollection` of `FeaturePartProps`-typed features). Per-feature
4856
- * stable ids follow `${cm.id}:${part}:${index}`. Style is resolved from the
4857
- * three layers (graphicsStyle, cm.style, generator hint) and stamped on
4858
- * `properties.style` when non-empty.
4859
- */
4860
- function renderControlMeasure(cm, opts) {
4861
- const features = dispatchControlMeasure(cm, opts).features.map((feature, index) => normalizeFeature(cm.id, feature, index, cm.style, opts?.graphicsStyle));
4862
- const bbox = computeBBox(features);
4863
- return {
4864
- type: "FeatureCollection",
4865
- ...bbox ? { bbox } : {},
4866
- features
4867
- };
4868
- }
4869
- const EMPTY_COLLECTION = {
4870
- type: "FeatureCollection",
4871
- features: []
4872
- };
4873
- function dispatchControlMeasure(cm, opts) {
4874
- const definition = DEFINITIONS[cm.kind];
4875
- if (!validateInputContract(cm.controlPoints, definition.metadata, opts?.validationMode)) return EMPTY_COLLECTION;
4876
- const generator = definition.generator;
4877
- return generator(cm.controlPoints, cm.options ?? {});
4878
- }
4879
- /**
4880
- * The control-point *input contract*: at least `metadata.minCoordinates` points,
4881
- * each a finite `[lon, lat]` position. Reports per `validationMode` on
4882
- * violation (`silent` → empty render, `warn` → log, `throw` → raise) and
4883
- * returns whether the points may be passed to the generator. Extra points
4884
- * beyond `maxCoordinates` are ignored, not rejected (ADR-0014).
4885
- */
4886
- function validateInputContract(points, metadata, mode) {
4887
- const minimum = metadata.minCoordinates ?? 0;
4888
- if (points.length < minimum) return reportValidationIssue(mode, `"${metadata.id}" requires at least ${minimum} control points; received ${points.length}.`);
4889
- if (points.some((point) => !isValidPosition(point))) return reportValidationIssue(mode, `"${metadata.id}" control points must contain finite [lon, lat] values.`);
4890
- return true;
4891
- }
4892
- function normalizeFeature(id, feature, index, measureStyle, graphicsStyle) {
4893
- const sourceProps = feature.properties ?? {};
4894
- const part = typeof sourceProps.part === "string" && sourceProps.part.length > 0 ? sourceProps.part : "main";
4895
- const generatorStyle = typeof sourceProps.style === "object" && sourceProps.style !== null ? sourceProps.style : void 0;
4896
- const filled = sourceProps.fill === true;
4897
- const style = resolveSymbolColor(resolveStyleHints(graphicsStyle, measureStyle, generatorStyle), filled);
4898
- const text = typeof sourceProps.text === "string" ? sourceProps.text : void 0;
4899
- const rotation = typeof sourceProps.rotation === "number" ? sourceProps.rotation : void 0;
4900
- const textSizePixels = typeof sourceProps.textSizePixels === "number" ? sourceProps.textSizePixels : void 0;
4901
- const textSizeZoom = typeof sourceProps.textSizeZoom === "number" ? sourceProps.textSizeZoom : void 0;
4902
- const textSizeResolution = typeof sourceProps.textSizeResolution === "number" ? sourceProps.textSizeResolution : void 0;
4903
- return {
4904
- type: "Feature",
4905
- id: `${id}:${part}:${index}`,
4906
- properties: {
4907
- part,
4908
- index,
4909
- style,
4910
- ...text !== void 0 ? { text } : {},
4911
- ...rotation !== void 0 ? { rotation } : {},
4912
- ...textSizePixels !== void 0 ? { textSizePixels } : {},
4913
- ...textSizeZoom !== void 0 ? { textSizeZoom } : {},
4914
- ...textSizeResolution !== void 0 ? { textSizeResolution } : {}
4915
- },
4916
- geometry: feature.geometry
4917
- };
4918
- }
4919
- function computeBBox(features) {
4920
- let bbox;
4921
- for (const feature of features) visitGeometryPositions(feature.geometry, ([x, y]) => {
4922
- if (bbox) bbox = [
4923
- Math.min(bbox[0], x),
4924
- Math.min(bbox[1], y),
4925
- Math.max(bbox[2], x),
4926
- Math.max(bbox[3], y)
4927
- ];
4928
- else bbox = [
4929
- x,
4930
- y,
4931
- x,
4932
- y
4933
- ];
4934
- });
4935
- return bbox;
4936
- }
4937
- function visitGeometryPositions(geometry, visit) {
4938
- switch (geometry.type) {
4939
- case "Point":
4940
- visit(geometry.coordinates);
4941
- break;
4942
- case "MultiPoint":
4943
- case "LineString":
4944
- geometry.coordinates.forEach(visit);
4945
- break;
4946
- case "MultiLineString":
4947
- case "Polygon":
4948
- geometry.coordinates.forEach((line) => line.forEach(visit));
4949
- break;
4950
- case "MultiPolygon":
4951
- geometry.coordinates.forEach((polygon) => {
4952
- polygon.forEach((line) => line.forEach(visit));
4953
- });
4954
- break;
4955
- case "GeometryCollection":
4956
- geometry.geometries.forEach((item) => visitGeometryPositions(item, visit));
4957
- break;
4958
- default: assertNever(geometry);
4959
- }
4960
- }
4961
- function assertNever(value) {
4962
- throw new Error(`Unhandled control measure kind: ${String(value)}`);
4963
- }
4964
- //#endregion
1
+ import { $ as snapToMidpointPerpendicular, A as DEFAULT_BLOCK_MISSION_TASK_OPTIONS, B as line24DrawRule, C as DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS, D as DEFAULT_CANALIZE_OPTIONS, E as DEFAULT_CLEAR_OPTIONS, F as DEFAULT_ANTITANK_DITCH_OPTIONS, G as attackByFireDrawRule, H as turnDrawRule, I as DEFAULT_AMBUSH_OPTIONS, J as blockDrawRule, K as point12DrawRule, L as DEFAULT_AIRBORNE_ATTACK_OPTIONS, M as DEFAULT_ATTACK_HELICOPTER_OPTIONS, N as DEFAULT_ATTACK_BY_FIRE_OPTIONS, O as DEFAULT_BYPASS_OPTIONS, P as DEFAULT_ANTITANK_WALL_OPTIONS, Q as pointOnMidpointPerpendicularAxis, R as axis1DrawRule, S as DEFAULT_FIX_OPTIONS, T as DEFAULT_DELAY_OPTIONS, U as line1DrawRule, V as line23DrawRule, W as ambushDrawRule, X as createMidpointPerpendicularDrawRule, Y as computeDefaultMidpointPerpendicularPoint, Z as getMidpointPerpendicularSignedDistance, _ as DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS, at as EPSILON, b as DEFAULT_FORTIFIED_LINE_OPTIONS, c as getDefaultOptions, d as DEFAULT_SUPPORTING_ATTACK_OPTIONS, et as createBaselineFrame, f as DEFAULT_SUPPORT_BY_FIRE_OPTIONS, g as DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS, h as DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS, i as CONTROL_MEASURE_METADATA, it as unproject, j as DEFAULT_BLOCK_OPTIONS, k as DEFAULT_BREACH_OPTIONS, l as listControlMeasureMetadata, m as DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS, n as resolveStyleHints, nt as computeInitialWidthPoint, o as getControlMeasureMetadata, ot as getMetersPerPixel, p as DEFAULT_TACTICAL_ARROW_OPTIONS, q as disruptDrawRule, r as CONTROL_MEASURE_IDS, rt as project, s as getControlMeasureMetadataByValue, st as roundToFixed, t as renderControlMeasure, tt as calculateMetrics, u as DEFAULT_TURN_OPTIONS, v as DEFAULT_MAIN_ATTACK_OPTIONS, w as DEFAULT_DISRUPT_OPTIONS, x as DEFAULT_FLOT_OPTIONS, y as DEFAULT_FORTIFIED_AREA_OPTIONS, z as supportByFireDrawRule } from "./renderControlMeasure-C7pY-TcL.mjs";
4965
2
  //#region src/instance.ts
4966
3
  function isKind(cm, kind) {
4967
4
  return cm.kind === kind;