@orbat-mapper/control-measures 0.19.0 → 0.21.0

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.
@@ -1,3 +1,50 @@
1
+ //#region src/style.ts
2
+ /**
3
+ * Per-feature style hint pinning a filled part to a solid interior. Attach to
4
+ * a generator's intrinsic doctrinal accents — arrowheads, teeth, echelon
5
+ * glyphs, barbs, blades — so a patterned (`hatch`, `dots`, …) fill set at the graphicsStyle
6
+ * or measure layer never bleeds into a small silhouette and ruins its
7
+ * legibility. The renderer only reads style hints, so one shared instance is
8
+ * safe to reuse across every accent feature.
9
+ */
10
+ const SOLID_ACCENT_FILL = { fillPattern: "solid" };
11
+ /**
12
+ * Ultimate fallback for the symbol color when no `color` or per-channel
13
+ * override is supplied at any layer. Keeps a zero-config render monocolor
14
+ * black and guarantees filled parts still render filled. See ADR-0011.
15
+ */
16
+ const DEFAULT_SYMBOL_COLOR = "#000000";
17
+ //#endregion
18
+ //#region src/portrayal.ts
19
+ const DEFAULT_STROKE_WIDTH_CSS_PIXELS = 2;
20
+ const DEFAULT_STROKE_DASH_CSS_PIXELS = Object.freeze([]);
21
+ const DEFAULT_LINE_CAP = "round";
22
+ const DEFAULT_LINE_JOIN = "round";
23
+ const DEFAULT_LABEL_HEIGHT_CSS_PIXELS = 14;
24
+ const DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS = {
25
+ min: 8,
26
+ max: 24
27
+ };
28
+ /** Shared absent-value portrayal defaults; render output remains sparse. */
29
+ const DEFAULT_PORTRAYAL = Object.freeze({
30
+ symbolColor: DEFAULT_SYMBOL_COLOR,
31
+ strokeWidthCssPixels: 2,
32
+ strokeDashCssPixels: DEFAULT_STROKE_DASH_CSS_PIXELS,
33
+ lineCap: DEFAULT_LINE_CAP,
34
+ lineJoin: DEFAULT_LINE_JOIN,
35
+ labelHeightCssPixels: 14
36
+ });
37
+ //#endregion
38
+ //#region src/define.ts
39
+ /**
40
+ * Identity helper that infers a definition's literal `Id` and precise generator
41
+ * type `G`, so the registry's `typeof DEFINITIONS` carries each measure's exact
42
+ * options type. Internal authoring API — not part of the published surface.
43
+ */
44
+ function defineControlMeasure(definition) {
45
+ return definition;
46
+ }
47
+ //#endregion
1
48
  //#region src/internal/graphics-utils.ts
2
49
  /**
3
50
  * Small value used to avoid division by zero and floating-point precision issues.
@@ -275,334 +322,100 @@ function lineIntersection(p1, p2, p3, p4) {
275
322
  return [x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)];
276
323
  }
277
324
  //#endregion
278
- //#region src/attack-utils.ts
279
- const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
280
- const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
281
- const SHAFT_MIN_RATIO = .1;
282
- const SHAFT_MAX_RATIO = .9;
283
- const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
284
- const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
285
- /**
286
- * Creates the definition-owned rear-/shaft-width handle pair shared by every
287
- * variable-width arrow body. Callers supply only how their own geometry maps
288
- * onto {@link WidthHandleGeometry}; placement (the two ends of the right edge)
289
- * and the drag math (perpendicular distance from the dragged point to the
290
- * matching centerline segment, over the reference half-width) live here once.
291
- */
292
- function createWidthOptionHandles(config) {
325
+ //#region src/draw-rules/baseline-frame.ts
326
+ function createBaselineFrame(p1, p2, options = {}) {
327
+ return createProjectedBaselineFrame(project(p1[0], p1[1]), project(p2[0], p2[1]), options);
328
+ }
329
+ function createProjectedBaselineFrame(p1, p2, options = {}) {
330
+ const baseline = vecSub(p2, p1);
331
+ const length = vecMag(baseline);
332
+ if (length < 1e-6) return null;
333
+ const direction = vecNorm(baseline);
334
+ const normalSign = options.normal === "left" ? -1 : 1;
335
+ const normal = vecScale([direction[1], -direction[0]], normalSign);
336
+ const origin = getOrigin(p1, p2, options.origin ?? "midpoint");
293
337
  return {
294
- get(controlPoints, options) {
295
- const geometry = config.geometry(controlPoints, options);
296
- const rearRight = geometry?.rightEdge[0];
297
- const neckRight = geometry?.rightEdge.at(-1);
298
- if (!rearRight || !neckRight) return [];
299
- return [{
300
- id: REAR_WIDTH_OPTION_HANDLE_ID,
301
- position: unproject(rearRight[0], rearRight[1])
302
- }, {
303
- id: SHAFT_WIDTH_OPTION_HANDLE_ID,
304
- position: unproject(neckRight[0], neckRight[1])
305
- }];
338
+ p1,
339
+ p2,
340
+ origin,
341
+ direction,
342
+ normal,
343
+ length,
344
+ signedNormalDistance(point) {
345
+ return vecDot(vecSub(project(point[0], point[1]), origin), normal);
306
346
  },
307
- drag({ controlPoints, options, handleId, position }) {
308
- const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
309
- if (!rear && !(handleId === "shaft-width")) return void 0;
310
- const geometry = config.geometry(controlPoints, options);
311
- if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
312
- const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
313
- const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
314
- if (!from || !to) return void 0;
315
- const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
316
- return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
347
+ pointAtNormalDistance(distance) {
348
+ if (!Number.isFinite(distance)) return null;
349
+ const point = vecAdd(origin, vecScale(normal, distance));
350
+ return unproject(point[0], point[1]);
317
351
  }
318
352
  };
319
353
  }
320
- /**
321
- * The width handles for attack bodies built by {@link processAttackGeometry},
322
- * whose ratios are fractions of the arrowhead half-width. A coordinate resolver
323
- * lets derivative measures (Counterattack by Fire) map their authored Axis1
324
- * points to the body coordinates first.
325
- */
326
- function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) => points) {
327
- return createWidthOptionHandles({
328
- geometry(controlPoints, options) {
329
- const coordinates = resolveCoordinates(controlPoints);
330
- if (!coordinates) return null;
331
- const { geometry } = processAttackGeometry(coordinates, options);
332
- const outerLeft = geometry?.headRing[0];
333
- if (!geometry || !outerLeft) return null;
334
- return {
335
- spine: geometry.shaftCenterline,
336
- rightEdge: geometry.shaftRight,
337
- referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
338
- };
339
- },
340
- minRatio: SHAFT_MIN_RATIO,
341
- maxRearRatio: 3,
342
- maxShaftRatio: SHAFT_MAX_RATIO
343
- });
354
+ function getOrigin(p1, p2, origin) {
355
+ if (origin === "p1") return p1;
356
+ if (origin === "p2") return p2;
357
+ return vecScale(vecAdd(p1, p2), .5);
344
358
  }
345
- const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles();
346
- /**
347
- * The single home of the "rear inherits shaft" rule: an omitted `rearWidthRatio`
348
- * leaves the body parallel by matching the effective shaft ratio. Every
349
- * variable-width body resolves the option through here so the rule cannot drift.
350
- */
351
- function resolveRearWidthRatio(options, shaftRatio) {
352
- return options.rearWidthRatio ?? shaftRatio;
359
+ //#endregion
360
+ //#region src/draw-rules/util.ts
361
+ function clonePosition(position) {
362
+ return [...position];
353
363
  }
354
- /**
355
- * Processes input coordinates and options to generate the core symbol geometry.
356
- * This handles validation, default values, projection, and geometry calculation.
357
- */
358
- function processAttackGeometry(coordinates, options = {}) {
359
- const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
360
- const smooth = options.smooth ?? false;
361
- const smoothResolution = options.smoothResolution ?? 5;
362
- const rearRatio = clamp(resolveRearWidthRatio(options, shaftRatio), SHAFT_MIN_RATIO, 3);
363
- const points = coordinates.map((c) => project(c[0], c[1]));
364
- const numPoints = points.length;
365
- const ptTip = points[0];
366
- const ptWidth = points[numPoints - 1];
367
- const spinePoints = [];
368
- for (let i = numPoints - 2; i >= 1; i--) {
369
- const p = points[i];
370
- if (p) spinePoints.push(p);
371
- }
372
- return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
364
+ function clonePositions(positions) {
365
+ return positions.map(clonePosition);
373
366
  }
374
- function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
375
- const initialNeck = spine[spine.length - 1];
376
- if (!initialNeck) return null;
377
- const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
378
- if (vecMag(initialTipDir) < 1e-6) return null;
379
- const headHalfWidth = pointToInfiniteLineDistance(ptWidth, initialNeck, ptTip);
380
- const vecTipToWidth = vecSub(ptTip, ptWidth);
381
- const requestedHeadLength = Math.max(EPSILON, vecDot(vecTipToWidth, initialTipDir));
382
- const path = [...spine, ptTip];
383
- let totalArcLength = 0;
384
- for (let i = 1; i < path.length; i++) totalArcLength += vecMag(vecSub(path[i], path[i - 1]));
385
- const minimumShaftLength = Math.max(EPSILON, totalArcLength * .01);
386
- let distanceRemaining = clamp(requestedHeadLength, EPSILON, Math.max(EPSILON, totalArcLength - minimumShaftLength));
387
- let ptBase = ptTip;
388
- let remainingSpine = spine;
389
- for (let i = path.length - 1; i > 0; i--) {
390
- const segmentEnd = path[i];
391
- const segmentStart = path[i - 1];
392
- const segment = vecSub(segmentStart, segmentEnd);
393
- const segmentLength = vecMag(segment);
394
- if (segmentLength < 1e-6) continue;
395
- if (distanceRemaining < segmentLength) {
396
- ptBase = vecAdd(segmentEnd, vecScale(segment, distanceRemaining / segmentLength));
397
- remainingSpine = path.slice(0, i);
398
- break;
367
+ function samePosition(a, b) {
368
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
369
+ }
370
+ //#endregion
371
+ //#region src/draw-rules/midpoint-perpendicular.ts
372
+ function createMidpointPerpendicularDrawRule(options) {
373
+ const defaultDistanceRatio = options.defaultDistanceRatio ?? 1.5;
374
+ const variableLength = options.variableLength ?? false;
375
+ const constrainedIndex = options.constrainedIndex ?? 2;
376
+ const baselineIndexA = constrainedIndex === 0 ? 1 : 0;
377
+ const baselineIndexB = constrainedIndex === 0 ? 2 : 1;
378
+ const frameOrigin = options.origin ?? "midpoint";
379
+ const frameNormal = options.normal ?? "right";
380
+ function frameFor(p1, p2) {
381
+ return createBaselineFrame(p1, p2, {
382
+ origin: frameOrigin,
383
+ normal: frameNormal
384
+ });
385
+ }
386
+ /** Slot holding the constrained point in an array of `count` points. */
387
+ function constrainedIndexIn(count) {
388
+ return variableLength ? count - 1 : constrainedIndex;
389
+ }
390
+ function packSlots(points, baselineA, baselineB, constrained) {
391
+ const out = points.length > 3 ? clonePositions(points) : new Array(3);
392
+ out[baselineIndexA] = baselineA;
393
+ out[baselineIndexB] = baselineB;
394
+ out[constrainedIndexIn(out.length)] = constrained;
395
+ return out;
396
+ }
397
+ function snap(baselineA, baselineB, point) {
398
+ const frame = frameFor(baselineA, baselineB);
399
+ if (!frame) return null;
400
+ return frame.pointAtNormalDistance(frame.signedNormalDistance(point));
401
+ }
402
+ function derive(points) {
403
+ if (points.length < 2) return clonePositions(points);
404
+ if (points.length === 2) {
405
+ const baselineA = clonePosition(points[0]);
406
+ const baselineB = clonePosition(points[1]);
407
+ const frame = frameFor(baselineA, baselineB);
408
+ const derived = frame ? frame.pointAtNormalDistance(frame.length * defaultDistanceRatio) : null;
409
+ return derived ? packSlots([
410
+ baselineA,
411
+ baselineB,
412
+ derived
413
+ ], baselineA, baselineB, derived) : [baselineA, baselineB];
399
414
  }
400
- distanceRemaining -= segmentLength;
401
- }
402
- const ptNeck = remainingSpine[remainingSpine.length - 1];
403
- if (!ptNeck) return null;
404
- const tipDir = vecNorm(vecSub(ptTip, ptNeck));
405
- if (vecMag(tipDir) < 1e-6) return null;
406
- const headLength = vecMag(vecSub(ptTip, ptBase));
407
- const shaftHalfWidth = headHalfWidth * shaftRatio;
408
- const perpDir = [-tipDir[1], tipDir[0]];
409
- const outerLeft = vecAdd(ptBase, vecScale(perpDir, headHalfWidth));
410
- const outerRight = vecAdd(ptBase, vecScale(perpDir, -headHalfWidth));
411
- const innerLeft = vecAdd(ptBase, vecScale(perpDir, shaftHalfWidth));
412
- const innerRight = vecAdd(ptBase, vecScale(perpDir, -shaftHalfWidth));
413
- const notchDepth = headLength * shaftRatio;
414
- const safeNotchDepth = Math.min(notchDepth, headLength - EPSILON);
415
- const innerTip = vecAdd(ptBase, vecScale(tipDir, safeNotchDepth));
416
- const shaftEndCenter = ptBase;
417
- const headRing = [
418
- outerLeft,
419
- ptTip,
420
- outerRight,
421
- innerRight,
422
- innerTip,
423
- innerLeft,
424
- outerLeft
425
- ];
426
- const fullSpine = [...remainingSpine, shaftEndCenter];
427
- const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
428
- return {
429
- shaftCenterline: fullSpine,
430
- shaftLeft,
431
- shaftRight,
432
- headRing,
433
- ptTip,
434
- ptNeck,
435
- ptBase
436
- };
437
- }
438
- /**
439
- * Calculates the relative metrics (longitudinal and lateral) of the width point
440
- * relative to the Tip-Neck axis.
441
- *
442
- * @param pts - Array of positions [Tip, Neck, ..., WidthPoint]
443
- * @returns { longitudinal: number, lateral: number } | null
444
- */
445
- function calculateMetrics(pts) {
446
- const tip = pts[0];
447
- const neck = pts[1];
448
- const widthPt = pts[pts.length - 1];
449
- if (!tip || !neck || !widthPt) return null;
450
- const tipM = project(tip[0], tip[1]);
451
- const neckM = project(neck[0], neck[1]);
452
- const widthM = project(widthPt[0], widthPt[1]);
453
- const tx = tipM[0];
454
- const ty = tipM[1];
455
- const nx = neckM[0];
456
- const ny = neckM[1];
457
- const wx = widthM[0];
458
- const wy = widthM[1];
459
- const dx = tx - nx;
460
- const dy = ty - ny;
461
- const len = Math.sqrt(dx * dx + dy * dy);
462
- if (len < 1e-6) return null;
463
- const ax = dx / len;
464
- const ay = dy / len;
465
- const px = -ay;
466
- const py = ax;
467
- const vx = wx - tx;
468
- const vy = wy - ty;
469
- return {
470
- longitudinal: vx * ax + vy * ay,
471
- lateral: vx * px + vy * py
472
- };
473
- }
474
- /**
475
- * Computes an initial Width Point based on the Tip and Neck positions.
476
- * It places the point perpendicular to the neck at a default distance relative to the length.
477
- *
478
- * @param tip - Tip position (Lon/Lat)
479
- * @param neck - Neck position (Lon/Lat)
480
- * @returns Width Point position (Lon/Lat)
481
- */
482
- function computeInitialWidthPoint(tip, neck) {
483
- const tipM = project(tip[0], tip[1]);
484
- const neckM = project(neck[0], neck[1]);
485
- const tx = tipM[0];
486
- const ty = tipM[1];
487
- const nx = neckM[0];
488
- const ny = neckM[1];
489
- const dx = tx - nx;
490
- const dy = ty - ny;
491
- const len = Math.sqrt(dx * dx + dy * dy);
492
- if (len < 1e-6) return unproject(nx + 100, ny);
493
- const ax = dx / len;
494
- const ay = dy / len;
495
- const widthRatio = .35;
496
- const recessionRatio = .25;
497
- const px = -ay;
498
- const py = ax;
499
- return unproject(tx - dx * recessionRatio + len * widthRatio * px, ty - dy * recessionRatio + len * widthRatio * py);
500
- }
501
- //#endregion
502
- //#region src/define.ts
503
- /**
504
- * Identity helper that infers a definition's literal `Id` and precise generator
505
- * type `G`, so the registry's `typeof DEFINITIONS` carries each measure's exact
506
- * options type. Internal authoring API — not part of the published surface.
507
- */
508
- function defineControlMeasure(definition) {
509
- return definition;
510
- }
511
- //#endregion
512
- //#region src/draw-rules/baseline-frame.ts
513
- function createBaselineFrame(p1, p2, options = {}) {
514
- return createProjectedBaselineFrame(project(p1[0], p1[1]), project(p2[0], p2[1]), options);
515
- }
516
- function createProjectedBaselineFrame(p1, p2, options = {}) {
517
- const baseline = vecSub(p2, p1);
518
- const length = vecMag(baseline);
519
- if (length < 1e-6) return null;
520
- const direction = vecNorm(baseline);
521
- const normalSign = options.normal === "left" ? -1 : 1;
522
- const normal = vecScale([direction[1], -direction[0]], normalSign);
523
- const origin = getOrigin(p1, p2, options.origin ?? "midpoint");
524
- return {
525
- p1,
526
- p2,
527
- origin,
528
- direction,
529
- normal,
530
- length,
531
- signedNormalDistance(point) {
532
- return vecDot(vecSub(project(point[0], point[1]), origin), normal);
533
- },
534
- pointAtNormalDistance(distance) {
535
- if (!Number.isFinite(distance)) return null;
536
- const point = vecAdd(origin, vecScale(normal, distance));
537
- return unproject(point[0], point[1]);
538
- }
539
- };
540
- }
541
- function getOrigin(p1, p2, origin) {
542
- if (origin === "p1") return p1;
543
- if (origin === "p2") return p2;
544
- return vecScale(vecAdd(p1, p2), .5);
545
- }
546
- //#endregion
547
- //#region src/draw-rules/util.ts
548
- function clonePosition(position) {
549
- return [...position];
550
- }
551
- function clonePositions(positions) {
552
- return positions.map(clonePosition);
553
- }
554
- function samePosition(a, b) {
555
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
556
- }
557
- //#endregion
558
- //#region src/draw-rules/midpoint-perpendicular.ts
559
- function createMidpointPerpendicularDrawRule(options) {
560
- const defaultDistanceRatio = options.defaultDistanceRatio ?? 1.5;
561
- const variableLength = options.variableLength ?? false;
562
- const constrainedIndex = options.constrainedIndex ?? 2;
563
- const baselineIndexA = constrainedIndex === 0 ? 1 : 0;
564
- const baselineIndexB = constrainedIndex === 0 ? 2 : 1;
565
- const frameOrigin = options.origin ?? "midpoint";
566
- const frameNormal = options.normal ?? "right";
567
- function frameFor(p1, p2) {
568
- return createBaselineFrame(p1, p2, {
569
- origin: frameOrigin,
570
- normal: frameNormal
571
- });
572
- }
573
- /** Slot holding the constrained point in an array of `count` points. */
574
- function constrainedIndexIn(count) {
575
- return variableLength ? count - 1 : constrainedIndex;
576
- }
577
- function packSlots(points, baselineA, baselineB, constrained) {
578
- const out = points.length > 3 ? clonePositions(points) : new Array(3);
579
- out[baselineIndexA] = baselineA;
580
- out[baselineIndexB] = baselineB;
581
- out[constrainedIndexIn(out.length)] = constrained;
582
- return out;
583
- }
584
- function snap(baselineA, baselineB, point) {
585
- const frame = frameFor(baselineA, baselineB);
586
- if (!frame) return null;
587
- return frame.pointAtNormalDistance(frame.signedNormalDistance(point));
588
- }
589
- function derive(points) {
590
- if (points.length < 2) return clonePositions(points);
591
- if (points.length === 2) {
592
- const baselineA = clonePosition(points[0]);
593
- const baselineB = clonePosition(points[1]);
594
- const frame = frameFor(baselineA, baselineB);
595
- const derived = frame ? frame.pointAtNormalDistance(frame.length * defaultDistanceRatio) : null;
596
- return derived ? packSlots([
597
- baselineA,
598
- baselineB,
599
- derived
600
- ], baselineA, baselineB, derived) : [baselineA, baselineB];
601
- }
602
- const baselineA = clonePosition(points[baselineIndexA]);
603
- const baselineB = clonePosition(points[baselineIndexB]);
604
- const rawConstrained = points[constrainedIndexIn(points.length)];
605
- return packSlots(points, baselineA, baselineB, snap(baselineA, baselineB, rawConstrained) ?? clonePosition(rawConstrained));
415
+ const baselineA = clonePosition(points[baselineIndexA]);
416
+ const baselineB = clonePosition(points[baselineIndexB]);
417
+ const rawConstrained = points[constrainedIndexIn(points.length)];
418
+ return packSlots(points, baselineA, baselineB, snap(baselineA, baselineB, rawConstrained) ?? clonePosition(rawConstrained));
606
419
  }
607
420
  return {
608
421
  id: options.id,
@@ -1558,1177 +1371,495 @@ const supportByFireDrawRule = {
1558
1371
  }
1559
1372
  };
1560
1373
  //#endregion
1561
- //#region src/draw-rules/axis1.ts
1562
- function derive$2(points) {
1563
- if (points.length < 2) return clonePositions(points);
1564
- if (points.length === 2) {
1565
- const tip = clonePosition(points[0]);
1566
- const neck = clonePosition(points[1]);
1567
- return [
1568
- tip,
1569
- neck,
1570
- computeInitialWidthPoint(tip, neck)
1571
- ];
1572
- }
1573
- return clonePositions(points);
1574
- }
1575
- function projectWidthIntoFrame(next, longitudinal, lateral) {
1576
- const tip = next[0];
1577
- const neck = next[1];
1578
- if (!tip || !neck) return null;
1579
- const tipM = project(tip[0], tip[1]);
1580
- const neckM = project(neck[0], neck[1]);
1581
- const dx = tipM[0] - neckM[0];
1582
- const dy = tipM[1] - neckM[1];
1583
- const len = Math.sqrt(dx * dx + dy * dy);
1584
- if (len < 1e-6) return null;
1585
- const ax = dx / len;
1586
- const ay = dy / len;
1587
- const px = -ay;
1588
- const py = ax;
1589
- return unproject(tipM[0] + longitudinal * ax + lateral * px, tipM[1] + longitudinal * ay + lateral * py);
1590
- }
1591
- function packWithWidth(next, width) {
1592
- const out = clonePositions(next);
1593
- out[out.length - 1] = width;
1594
- return out;
1595
- }
1596
- const axis1DrawRule = {
1597
- id: "axis1",
1598
- minimumUserPoints: 2,
1599
- trailingFixedSlots: 1,
1600
- derive: derive$2,
1601
- transform(event) {
1602
- const { previous, next, activePointIndex } = event;
1603
- if (next.length < 3 || previous.length < 3) return derive$2(next);
1604
- if (!(activePointIndex === 0 || activePointIndex === 1)) return clonePositions(next);
1605
- const metrics = calculateMetrics(previous);
1606
- if (!metrics) return clonePositions(next);
1607
- const projected = projectWidthIntoFrame(next, metrics.longitudinal, metrics.lateral);
1608
- if (!projected) return clonePositions(next);
1609
- return packWithWidth(next, projected);
1610
- }
1611
- };
1374
+ //#region src/attack-utils.ts
1375
+ const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
1376
+ const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
1377
+ const SHAFT_MIN_RATIO = .1;
1378
+ const SHAFT_MAX_RATIO = .9;
1379
+ const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
1380
+ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1612
1381
  /**
1613
- * Recesses the CATK body from P1. The original P1 remains the tip of the fire
1614
- * arrow, while P2 and the remaining Axis1 points continue to shape the body.
1615
- */
1616
- function counterattackByFireBodyCoordinates(points) {
1617
- const fireTipCoordinate = points[0];
1618
- const neckCoordinate = points[1];
1619
- if (!fireTipCoordinate || !neckCoordinate) return null;
1620
- const fireTip = project(fireTipCoordinate[0], fireTipCoordinate[1]);
1621
- const terminal = vecSub(fireTip, project(neckCoordinate[0], neckCoordinate[1]));
1622
- if (vecMag(terminal) < 1e-6) return null;
1623
- const bodyTip = vecAdd(fireTip, vecScale(terminal, -.3));
1624
- return [unproject(bodyTip[0], bodyTip[1]), ...points.slice(1)];
1625
- }
1626
- function restoreFireTip(points, fireTip) {
1627
- if (points.length > 0 && fireTip) points[0] = clonePosition(fireTip);
1628
- return points;
1629
- }
1630
- /**
1631
- * Axis1 interaction for Counterattack by Fire, with its width frame anchored
1632
- * to the recessed CATK body tip rather than P1, which belongs to the separate
1633
- * fire arrow.
1382
+ * Creates the definition-owned rear-/shaft-width handle pair shared by every
1383
+ * variable-width arrow body. Callers supply only how their own geometry maps
1384
+ * onto {@link WidthHandleGeometry}; placement (the two ends of the right edge)
1385
+ * and the drag math (perpendicular distance from the dragged point to the
1386
+ * matching centerline segment, over the reference half-width) live here once.
1634
1387
  */
1635
- const counterattackByFireDrawRule = {
1636
- ...axis1DrawRule,
1637
- id: "axis1:counterattack-by-fire",
1638
- derive(points) {
1639
- const bodyPoints = counterattackByFireBodyCoordinates(points);
1640
- if (!bodyPoints) return axis1DrawRule.derive(points);
1641
- return restoreFireTip(axis1DrawRule.derive(bodyPoints), points[0]);
1642
- },
1643
- transform(event) {
1644
- const previousBody = counterattackByFireBodyCoordinates(event.previous);
1645
- const nextBody = counterattackByFireBodyCoordinates(event.next);
1646
- if (!previousBody || !nextBody) return axis1DrawRule.transform(event);
1647
- return restoreFireTip(axis1DrawRule.transform({
1648
- ...event,
1649
- previous: previousBody,
1650
- next: nextBody
1651
- }), event.next[0]);
1652
- }
1653
- };
1654
- //#endregion
1655
- //#region src/draw-rules/area21.ts
1656
- function derive$1(points) {
1657
- if (points.length === 2) return clonePositions([
1658
- points[0],
1659
- points[1],
1660
- points[1]
1661
- ]);
1662
- return clonePositions(points);
1388
+ function createWidthOptionHandles(config) {
1389
+ return {
1390
+ get(controlPoints, options) {
1391
+ const geometry = config.geometry(controlPoints, options);
1392
+ const rearRight = geometry?.rightEdge[0];
1393
+ const neckRight = geometry?.rightEdge.at(-1);
1394
+ if (!rearRight || !neckRight) return [];
1395
+ return [{
1396
+ id: REAR_WIDTH_OPTION_HANDLE_ID,
1397
+ position: unproject(rearRight[0], rearRight[1])
1398
+ }, {
1399
+ id: SHAFT_WIDTH_OPTION_HANDLE_ID,
1400
+ position: unproject(neckRight[0], neckRight[1])
1401
+ }];
1402
+ },
1403
+ drag({ controlPoints, options, handleId, position }) {
1404
+ const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
1405
+ if (!rear && !(handleId === "shaft-width")) return void 0;
1406
+ const geometry = config.geometry(controlPoints, options);
1407
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
1408
+ const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
1409
+ const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
1410
+ if (!from || !to) return void 0;
1411
+ const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
1412
+ return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
1413
+ }
1414
+ };
1663
1415
  }
1664
- const searchAreaDrawRule = {
1665
- id: "area21:search-area",
1666
- minimumUserPoints: 3,
1667
- minimumPreviewPoints: 2,
1668
- canonicalPointCount: 3,
1669
- derive: derive$1,
1670
- transform(event) {
1671
- return derive$1(event.next);
1672
- }
1673
- };
1674
- //#endregion
1675
- //#region src/draw-rules/rectangle.ts
1676
1416
  /**
1677
- * Oriented rectangle: P1 and P2 are adjacent corners and define the first
1678
- * edge. P3 is the next corner, constrained to the perpendicular through P2.
1679
- * The generator derives the fourth corner.
1417
+ * The width handles for attack bodies built by {@link processAttackGeometry},
1418
+ * whose ratios are fractions of the arrowhead half-width. A coordinate resolver
1419
+ * lets derivative measures (Counterattack by Fire) map their authored Axis1
1420
+ * points to the body coordinates first.
1680
1421
  */
1681
- function derive(points) {
1682
- if (points.length < 2) return clonePositions(points);
1683
- const p1 = clonePosition(points[0]);
1684
- const p2 = clonePosition(points[1]);
1685
- if (points.length === 2) return [
1686
- p1,
1687
- p2,
1688
- clonePosition(p2)
1689
- ];
1690
- const frame = createBaselineFrame(p1, p2, {
1691
- origin: "p2",
1692
- normal: "right"
1422
+ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) => points) {
1423
+ return createWidthOptionHandles({
1424
+ geometry(controlPoints, options) {
1425
+ const coordinates = resolveCoordinates(controlPoints);
1426
+ if (!coordinates) return null;
1427
+ const { geometry } = processAttackGeometry(coordinates, options);
1428
+ const outerLeft = geometry?.headRing[0];
1429
+ if (!geometry || !outerLeft) return null;
1430
+ return {
1431
+ spine: geometry.shaftCenterline,
1432
+ rightEdge: geometry.shaftRight,
1433
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
1434
+ };
1435
+ },
1436
+ minRatio: SHAFT_MIN_RATIO,
1437
+ maxRearRatio: 3,
1438
+ maxShaftRatio: SHAFT_MAX_RATIO
1693
1439
  });
1694
- return [
1695
- p1,
1696
- p2,
1697
- frame?.pointAtNormalDistance(frame.signedNormalDistance(points[2])) ?? clonePosition(points[2])
1698
- ];
1699
1440
  }
1700
- function guidePoints(points) {
1701
- const canonical = derive(points);
1702
- if (canonical.length < 3) return canonical;
1703
- const [c1, c2, c3] = canonical;
1704
- if (!c1 || !c2 || !c3) return canonical;
1705
- if (c2[0] === c3[0] && c2[1] === c3[1]) return [c1, c2];
1706
- const p1 = project(c1[0], c1[1]);
1707
- const p2 = project(c2[0], c2[1]);
1708
- const p4 = vecAdd(p1, vecSub(project(c3[0], c3[1]), p2));
1709
- return [
1710
- c1,
1711
- c2,
1712
- c3,
1713
- unproject(p4[0], p4[1]),
1714
- clonePosition(c1)
1715
- ];
1441
+ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles();
1442
+ /**
1443
+ * The single home of the "rear inherits shaft" rule: an omitted `rearWidthRatio`
1444
+ * leaves the body parallel by matching the effective shaft ratio. Every
1445
+ * variable-width body resolves the option through here so the rule cannot drift.
1446
+ */
1447
+ function resolveRearWidthRatio(options, shaftRatio) {
1448
+ return options.rearWidthRatio ?? shaftRatio;
1716
1449
  }
1717
- function snapToAxis(point, origin, direction) {
1718
- const snapped = vecAdd(origin, vecScale(direction, vecDot(vecSub(project(point[0], point[1]), origin), direction)));
1719
- return unproject(snapped[0], snapped[1]);
1450
+ /**
1451
+ * Processes input coordinates and options to generate the core symbol geometry.
1452
+ * This handles validation, default values, projection, and geometry calculation.
1453
+ */
1454
+ function processAttackGeometry(coordinates, options = {}) {
1455
+ const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
1456
+ const smooth = options.smooth ?? false;
1457
+ const smoothResolution = options.smoothResolution ?? 5;
1458
+ const rearRatio = clamp(resolveRearWidthRatio(options, shaftRatio), SHAFT_MIN_RATIO, 3);
1459
+ const points = coordinates.map((c) => project(c[0], c[1]));
1460
+ const numPoints = points.length;
1461
+ const ptTip = points[0];
1462
+ const ptWidth = points[numPoints - 1];
1463
+ const spinePoints = [];
1464
+ for (let i = numPoints - 2; i >= 1; i--) {
1465
+ const p = points[i];
1466
+ if (p) spinePoints.push(p);
1467
+ }
1468
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
1720
1469
  }
1721
- const rectangleDrawRule = {
1722
- id: "rectangle",
1723
- minimumUserPoints: 3,
1724
- minimumPreviewPoints: 2,
1725
- canonicalPointCount: 3,
1726
- showGuide: true,
1727
- guidePoints,
1728
- derive,
1729
- transform(event) {
1730
- const { previous, next, activePointIndex } = event;
1731
- if (previous.length < 3 || next.length < 3) return derive(next);
1732
- const frame = createBaselineFrame(previous[0], previous[1], {
1733
- origin: "p2",
1734
- normal: "right"
1735
- });
1736
- if (!frame) return derive(next);
1737
- if (activePointIndex === 0) return [
1738
- snapToAxis(next[0], frame.p2, frame.direction),
1739
- clonePosition(previous[1]),
1740
- clonePosition(previous[2])
1741
- ];
1742
- if (activePointIndex === 1) {
1743
- const p2 = snapToAxis(next[1], frame.p1, frame.direction);
1744
- const delta = vecSub(project(p2[0], p2[1]), frame.p2);
1745
- const projectedP3 = vecAdd(project(previous[2][0], previous[2][1]), delta);
1746
- const p3 = unproject(projectedP3[0], projectedP3[1]);
1747
- return [
1748
- clonePosition(previous[0]),
1749
- p2,
1750
- p3
1751
- ];
1752
- }
1753
- if (activePointIndex === 2) {
1754
- const p3 = frame.pointAtNormalDistance(frame.signedNormalDistance(next[2]));
1755
- if (p3) return [
1756
- clonePosition(previous[0]),
1757
- clonePosition(previous[1]),
1758
- p3
1759
- ];
1470
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
1471
+ const initialNeck = spine[spine.length - 1];
1472
+ if (!initialNeck) return null;
1473
+ const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
1474
+ if (vecMag(initialTipDir) < 1e-6) return null;
1475
+ const headHalfWidth = pointToInfiniteLineDistance(ptWidth, initialNeck, ptTip);
1476
+ const vecTipToWidth = vecSub(ptTip, ptWidth);
1477
+ const requestedHeadLength = Math.max(EPSILON, vecDot(vecTipToWidth, initialTipDir));
1478
+ const path = [...spine, ptTip];
1479
+ let totalArcLength = 0;
1480
+ for (let i = 1; i < path.length; i++) totalArcLength += vecMag(vecSub(path[i], path[i - 1]));
1481
+ const minimumShaftLength = Math.max(EPSILON, totalArcLength * .01);
1482
+ let distanceRemaining = clamp(requestedHeadLength, EPSILON, Math.max(EPSILON, totalArcLength - minimumShaftLength));
1483
+ let ptBase = ptTip;
1484
+ let remainingSpine = spine;
1485
+ for (let i = path.length - 1; i > 0; i--) {
1486
+ const segmentEnd = path[i];
1487
+ const segmentStart = path[i - 1];
1488
+ const segment = vecSub(segmentStart, segmentEnd);
1489
+ const segmentLength = vecMag(segment);
1490
+ if (segmentLength < 1e-6) continue;
1491
+ if (distanceRemaining < segmentLength) {
1492
+ ptBase = vecAdd(segmentEnd, vecScale(segment, distanceRemaining / segmentLength));
1493
+ remainingSpine = path.slice(0, i);
1494
+ break;
1760
1495
  }
1761
- return derive(next);
1496
+ distanceRemaining -= segmentLength;
1762
1497
  }
1763
- };
1764
- //#endregion
1765
- //#region src/draw-rules/line25.ts
1766
- /** Line25: PT. 1 is the arrow tip; PT. 2 is the rear of the symbol. */
1767
- const line25DrawRule = {
1768
- id: "line25",
1769
- minimumUserPoints: 2,
1770
- canonicalPointCount: 2,
1771
- derive: (points) => clonePositions(points.slice(0, 2)),
1772
- transform: (event) => clonePositions(event.next.slice(0, 2))
1773
- };
1774
- //#endregion
1775
- //#region src/internal/echelon.ts
1498
+ const ptNeck = remainingSpine[remainingSpine.length - 1];
1499
+ if (!ptNeck) return null;
1500
+ const tipDir = vecNorm(vecSub(ptTip, ptNeck));
1501
+ if (vecMag(tipDir) < 1e-6) return null;
1502
+ const headLength = vecMag(vecSub(ptTip, ptBase));
1503
+ const shaftHalfWidth = headHalfWidth * shaftRatio;
1504
+ const perpDir = [-tipDir[1], tipDir[0]];
1505
+ const outerLeft = vecAdd(ptBase, vecScale(perpDir, headHalfWidth));
1506
+ const outerRight = vecAdd(ptBase, vecScale(perpDir, -headHalfWidth));
1507
+ const innerLeft = vecAdd(ptBase, vecScale(perpDir, shaftHalfWidth));
1508
+ const innerRight = vecAdd(ptBase, vecScale(perpDir, -shaftHalfWidth));
1509
+ const notchDepth = headLength * shaftRatio;
1510
+ const safeNotchDepth = Math.min(notchDepth, headLength - EPSILON);
1511
+ const innerTip = vecAdd(ptBase, vecScale(tipDir, safeNotchDepth));
1512
+ const shaftEndCenter = ptBase;
1513
+ const headRing = [
1514
+ outerLeft,
1515
+ ptTip,
1516
+ outerRight,
1517
+ innerRight,
1518
+ innerTip,
1519
+ innerLeft,
1520
+ outerLeft
1521
+ ];
1522
+ const fullSpine = [...remainingSpine, shaftEndCenter];
1523
+ const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
1524
+ return {
1525
+ shaftCenterline: fullSpine,
1526
+ shaftLeft,
1527
+ shaftRight,
1528
+ headRing,
1529
+ ptTip,
1530
+ ptNeck,
1531
+ ptBase
1532
+ };
1533
+ }
1776
1534
  /**
1777
- * APP-6 echelon (Field B) glyph vocabulary, shared by the control measures that
1778
- * stamp a unit-size amplifier onto their geometry (Boundary, Battle Position).
1535
+ * Calculates the relative metrics (longitudinal and lateral) of the width point
1536
+ * relative to the Tip-Neck axis.
1779
1537
  *
1780
- * A compact port of the APP-6 echelon designs, modeled on milsymbol but
1781
- * expressed as parametric geometry so it bakes into GeoJSON and strokes
1782
- * monocolor (ADR-0023). Every glyph is built from three primitives — vertical
1783
- * bars, crosses, and dots — plus the team/crew ring and command `++`.
1538
+ * @param pts - Array of positions [Tip, Neck, ..., WidthPoint]
1539
+ * @returns { longitudinal: number, lateral: number } | null
1784
1540
  */
1785
- const ECHELONS = {
1786
- team: {
1787
- kind: "ring",
1788
- count: 1,
1789
- label: "Team/Crew"
1790
- },
1791
- crew: {
1792
- kind: "ring",
1793
- count: 1
1794
- },
1795
- squad: {
1796
- kind: "dots",
1797
- count: 1,
1798
- label: "Squad"
1799
- },
1800
- section: {
1801
- kind: "dots",
1802
- count: 2,
1803
- label: "Section"
1804
- },
1805
- platoon: {
1806
- kind: "dots",
1807
- count: 3,
1808
- label: "Platoon"
1809
- },
1810
- company: {
1811
- kind: "bars",
1812
- count: 1,
1813
- label: "Company"
1814
- },
1815
- battalion: {
1816
- kind: "bars",
1817
- count: 2,
1818
- label: "Battalion"
1819
- },
1820
- regiment: {
1821
- kind: "bars",
1822
- count: 3,
1823
- label: "Regiment"
1824
- },
1825
- brigade: {
1826
- kind: "crosses",
1827
- count: 1,
1828
- label: "Brigade"
1829
- },
1830
- division: {
1831
- kind: "crosses",
1832
- count: 2,
1833
- label: "Division"
1834
- },
1835
- corps: {
1836
- kind: "crosses",
1837
- count: 3,
1838
- label: "Corps"
1839
- },
1840
- army: {
1841
- kind: "crosses",
1842
- count: 4,
1843
- label: "Army"
1844
- },
1845
- "army-group": {
1846
- kind: "crosses",
1847
- count: 5,
1848
- label: "Army Group"
1849
- },
1850
- region: {
1851
- kind: "crosses",
1852
- count: 6,
1853
- label: "Region"
1854
- },
1855
- command: {
1856
- kind: "command",
1857
- count: 2,
1858
- label: "Command"
1859
- }
1860
- };
1861
- /** Symbol-form aliases (`"XX"`, `"II"`, `"•••"`, …) → canonical echelon key. */
1862
- const ECHELON_ALIASES = {
1863
- ø: "team",
1864
- "•": "squad",
1865
- "••": "section",
1866
- "•••": "platoon",
1867
- i: "company",
1868
- ii: "battalion",
1869
- iii: "regiment",
1870
- x: "brigade",
1871
- xx: "division",
1872
- xxx: "corps",
1873
- xxxx: "army",
1874
- xxxxx: "army-group",
1875
- xxxxxx: "region",
1876
- "++": "command"
1877
- };
1878
- /** Enum options for the params panel, derived from {@link ECHELONS}. */
1879
- const ECHELON_PARAM_OPTIONS = [{
1880
- label: "None",
1881
- value: "none"
1882
- }, ...Object.entries(ECHELONS).filter(([, spec]) => spec.label !== void 0).map(([value, spec]) => ({
1883
- label: spec.label,
1884
- value
1885
- }))];
1541
+ function calculateMetrics(pts) {
1542
+ const tip = pts[0];
1543
+ const neck = pts[1];
1544
+ const widthPt = pts[pts.length - 1];
1545
+ if (!tip || !neck || !widthPt) return null;
1546
+ const tipM = project(tip[0], tip[1]);
1547
+ const neckM = project(neck[0], neck[1]);
1548
+ const widthM = project(widthPt[0], widthPt[1]);
1549
+ const tx = tipM[0];
1550
+ const ty = tipM[1];
1551
+ const nx = neckM[0];
1552
+ const ny = neckM[1];
1553
+ const wx = widthM[0];
1554
+ const wy = widthM[1];
1555
+ const dx = tx - nx;
1556
+ const dy = ty - ny;
1557
+ const len = Math.sqrt(dx * dx + dy * dy);
1558
+ if (len < 1e-6) return null;
1559
+ const ax = dx / len;
1560
+ const ay = dy / len;
1561
+ const px = -ay;
1562
+ const py = ax;
1563
+ const vx = wx - tx;
1564
+ const vy = wy - ty;
1565
+ return {
1566
+ longitudinal: vx * ax + vy * ay,
1567
+ lateral: vx * px + vy * py
1568
+ };
1569
+ }
1886
1570
  /**
1887
- * Effective echelon glyph height in meters. Screen-anchored when a pixel size
1888
- * is supplied with a live resolution; ground-anchored (meters) otherwise.
1889
- * Clamped strictly positive.
1571
+ * Computes an initial Width Point based on the Tip and Neck positions.
1572
+ * It places the point perpendicular to the neck at a default distance relative to the length.
1573
+ *
1574
+ * @param tip - Tip position (Lon/Lat)
1575
+ * @param neck - Neck position (Lon/Lat)
1576
+ * @returns Width Point position (Lon/Lat)
1890
1577
  */
1891
- function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
1892
- let h = echelonSize;
1893
- if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
1894
- return Math.max(EPSILON, h);
1578
+ function computeInitialWidthPoint(tip, neck) {
1579
+ const tipM = project(tip[0], tip[1]);
1580
+ const neckM = project(neck[0], neck[1]);
1581
+ const tx = tipM[0];
1582
+ const ty = tipM[1];
1583
+ const nx = neckM[0];
1584
+ const ny = neckM[1];
1585
+ const dx = tx - nx;
1586
+ const dy = ty - ny;
1587
+ const len = Math.sqrt(dx * dx + dy * dy);
1588
+ if (len < 1e-6) return unproject(nx + 100, ny);
1589
+ const ax = dx / len;
1590
+ const ay = dy / len;
1591
+ const widthRatio = .35;
1592
+ const recessionRatio = .25;
1593
+ const px = -ay;
1594
+ const py = ax;
1595
+ return unproject(tx - dx * recessionRatio + len * widthRatio * px, ty - dy * recessionRatio + len * widthRatio * py);
1895
1596
  }
1896
- function resolveEchelon(echelon) {
1897
- const key = echelon.trim().toLowerCase();
1898
- if (key.length === 0) return void 0;
1899
- const alias = ECHELON_ALIASES[key];
1900
- return ECHELONS[key] ?? (alias ? ECHELONS[alias] : void 0);
1597
+ //#endregion
1598
+ //#region src/draw-rules/axis1.ts
1599
+ function derive$2(points) {
1600
+ if (points.length < 2) return clonePositions(points);
1601
+ if (points.length === 2) {
1602
+ const tip = clonePosition(points[0]);
1603
+ const neck = clonePosition(points[1]);
1604
+ return [
1605
+ tip,
1606
+ neck,
1607
+ computeInitialWidthPoint(tip, neck)
1608
+ ];
1609
+ }
1610
+ return clonePositions(points);
1901
1611
  }
1902
- /**
1903
- * Builds the echelon glyph centered at `center`, with its baseline along
1904
- * `along` and `perp` (both unit vectors), `h` meters tall. Returns the glyph's
1905
- * `strokes` (open polylines), `fills` (closed rings drawn as solid shapes —
1906
- * the squad/section/platoon dots), and its intrinsic width along the line; the
1907
- * caller adds the configurable side padding to derive the line gap.
1908
- */
1909
- function buildEchelonGlyph(center, along, perp, h, spec) {
1910
- const P = (a, p) => {
1911
- const q = vecAdd(vecAdd(center, vecScale(along, a)), vecScale(perp, p));
1912
- return unproject(q[0], q[1]);
1913
- };
1914
- const ring = (a, r) => {
1915
- const pts = [];
1916
- const steps = 16;
1917
- for (let i = 0; i <= steps; i++) {
1918
- const t = 2 * Math.PI * i / steps;
1919
- pts.push(P(a + r * Math.cos(t), r * Math.sin(t)));
1920
- }
1921
- return pts;
1922
- };
1923
- const strokes = [];
1924
- const fills = [];
1925
- const half = h / 2;
1926
- switch (spec.kind) {
1927
- case "bars": {
1928
- const spacing = h * .8;
1929
- const total = (spec.count - 1) * spacing;
1930
- for (let j = 0; j < spec.count; j++) {
1931
- const a = -total / 2 + j * spacing;
1932
- strokes.push([P(a, -half), P(a, half)]);
1933
- }
1934
- return {
1935
- strokes,
1936
- fills,
1937
- width: total
1938
- };
1939
- }
1940
- case "crosses": {
1941
- const w = h;
1942
- const spacing = h * 1.4;
1943
- const total = (spec.count - 1) * spacing;
1944
- for (let j = 0; j < spec.count; j++) {
1945
- const a0 = -total / 2 + j * spacing;
1946
- strokes.push([P(a0 - w / 2, -half), P(a0 + w / 2, half)]);
1947
- strokes.push([P(a0 - w / 2, half), P(a0 + w / 2, -half)]);
1948
- }
1949
- return {
1950
- strokes,
1951
- fills,
1952
- width: total + w
1953
- };
1954
- }
1955
- case "dots": {
1956
- const r = h * .3;
1957
- const spacing = h * 1.2;
1958
- const total = (spec.count - 1) * spacing;
1959
- for (let j = 0; j < spec.count; j++) fills.push(ring(-total / 2 + j * spacing, r));
1960
- return {
1961
- strokes,
1962
- fills,
1963
- width: total + 2 * r
1964
- };
1965
- }
1966
- case "ring": {
1967
- const r = h * .6;
1968
- strokes.push(ring(0, r));
1969
- strokes.push([P(-h * .8, -h * .4), P(h * .8, h * .4)]);
1970
- return {
1971
- strokes,
1972
- fills,
1973
- width: h * 1.6
1974
- };
1975
- }
1976
- case "command": {
1977
- const arm = h * .5;
1978
- const spacing = h * 1.4;
1979
- const total = (spec.count - 1) * spacing;
1980
- for (let j = 0; j < spec.count; j++) {
1981
- const a0 = -total / 2 + j * spacing;
1982
- strokes.push([P(a0 - arm, 0), P(a0 + arm, 0)]);
1983
- strokes.push([P(a0, -half), P(a0, half)]);
1984
- }
1985
- return {
1986
- strokes,
1987
- fills,
1988
- width: total + 2 * arm
1989
- };
1990
- }
1612
+ function projectWidthIntoFrame(next, longitudinal, lateral) {
1613
+ const tip = next[0];
1614
+ const neck = next[1];
1615
+ if (!tip || !neck) return null;
1616
+ const tipM = project(tip[0], tip[1]);
1617
+ const neckM = project(neck[0], neck[1]);
1618
+ const dx = tipM[0] - neckM[0];
1619
+ const dy = tipM[1] - neckM[1];
1620
+ const len = Math.sqrt(dx * dx + dy * dy);
1621
+ if (len < 1e-6) return null;
1622
+ const ax = dx / len;
1623
+ const ay = dy / len;
1624
+ const px = -ay;
1625
+ const py = ax;
1626
+ return unproject(tipM[0] + longitudinal * ax + lateral * px, tipM[1] + longitudinal * ay + lateral * py);
1627
+ }
1628
+ function packWithWidth(next, width) {
1629
+ const out = clonePositions(next);
1630
+ out[out.length - 1] = width;
1631
+ return out;
1632
+ }
1633
+ const axis1DrawRule = {
1634
+ id: "axis1",
1635
+ minimumUserPoints: 2,
1636
+ trailingFixedSlots: 1,
1637
+ derive: derive$2,
1638
+ transform(event) {
1639
+ const { previous, next, activePointIndex } = event;
1640
+ if (next.length < 3 || previous.length < 3) return derive$2(next);
1641
+ if (!(activePointIndex === 0 || activePointIndex === 1)) return clonePositions(next);
1642
+ const metrics = calculateMetrics(previous);
1643
+ if (!metrics) return clonePositions(next);
1644
+ const projected = projectWidthIntoFrame(next, metrics.longitudinal, metrics.lateral);
1645
+ if (!projected) return clonePositions(next);
1646
+ return packWithWidth(next, projected);
1991
1647
  }
1648
+ };
1649
+ /**
1650
+ * Recesses the CATK body from P1. The original P1 remains the tip of the fire
1651
+ * arrow, while P2 and the remaining Axis1 points continue to shape the body.
1652
+ */
1653
+ function counterattackByFireBodyCoordinates(points) {
1654
+ const fireTipCoordinate = points[0];
1655
+ const neckCoordinate = points[1];
1656
+ if (!fireTipCoordinate || !neckCoordinate) return null;
1657
+ const fireTip = project(fireTipCoordinate[0], fireTipCoordinate[1]);
1658
+ const terminal = vecSub(fireTip, project(neckCoordinate[0], neckCoordinate[1]));
1659
+ if (vecMag(terminal) < 1e-6) return null;
1660
+ const bodyTip = vecAdd(fireTip, vecScale(terminal, -.3));
1661
+ return [unproject(bodyTip[0], bodyTip[1]), ...points.slice(1)];
1992
1662
  }
1993
- //#endregion
1994
- //#region src/generators/cm15-maneuver-areas/params.ts
1995
- const AREA_SMOOTH_PARAMS = [{
1996
- key: "smooth",
1997
- label: "Smooth",
1998
- description: "Round the area's corners by curving the boundary through the control points.",
1999
- type: "boolean"
2000
- }, {
2001
- key: "smoothResolution",
2002
- presentationTier: "advanced",
2003
- label: "Smooth resolution",
2004
- description: "Number of samples per segment when smooth mode is enabled.",
2005
- type: "number",
2006
- min: 2,
2007
- max: 64,
2008
- step: 1,
2009
- visibleWhen: (opts) => Boolean(opts.smooth)
2010
- }];
2011
- const ATTACK_SHAFT_PARAMS = [
2012
- {
2013
- key: "shaftWidthRatio",
2014
- presentationTier: "advanced",
2015
- label: "Shaft width",
2016
- description: "Arrow shaft width as a ratio of the arrowhead width",
2017
- type: "number",
2018
- min: .1,
2019
- max: 1,
2020
- step: .05
2021
- },
2022
- {
2023
- key: "rearWidthRatio",
2024
- presentationTier: "advanced",
2025
- label: "Rear width",
2026
- description: "Width at the rear of the shaft as a ratio of the arrowhead width.",
2027
- type: "number",
2028
- min: .1,
2029
- max: 2,
2030
- step: .05
2031
- },
2032
- {
2033
- key: "smooth",
2034
- label: "Smooth",
2035
- description: "Round the corners where the shaft changes direction.",
2036
- type: "boolean"
1663
+ function restoreFireTip(points, fireTip) {
1664
+ if (points.length > 0 && fireTip) points[0] = clonePosition(fireTip);
1665
+ return points;
1666
+ }
1667
+ /**
1668
+ * Axis1 interaction for Counterattack by Fire, with its width frame anchored
1669
+ * to the recessed CATK body tip rather than P1, which belongs to the separate
1670
+ * fire arrow.
1671
+ */
1672
+ const counterattackByFireDrawRule = {
1673
+ ...axis1DrawRule,
1674
+ id: "axis1:counterattack-by-fire",
1675
+ derive(points) {
1676
+ const bodyPoints = counterattackByFireBodyCoordinates(points);
1677
+ if (!bodyPoints) return axis1DrawRule.derive(points);
1678
+ return restoreFireTip(axis1DrawRule.derive(bodyPoints), points[0]);
2037
1679
  },
2038
- {
2039
- key: "smoothResolution",
2040
- presentationTier: "advanced",
2041
- label: "Smooth resolution",
2042
- description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
2043
- type: "number",
2044
- min: 1,
2045
- max: 20,
2046
- step: 1,
2047
- visibleWhen: (opts) => opts.smooth === true
1680
+ transform(event) {
1681
+ const previousBody = counterattackByFireBodyCoordinates(event.previous);
1682
+ const nextBody = counterattackByFireBodyCoordinates(event.next);
1683
+ if (!previousBody || !nextBody) return axis1DrawRule.transform(event);
1684
+ return restoreFireTip(axis1DrawRule.transform({
1685
+ ...event,
1686
+ previous: previousBody,
1687
+ next: nextBody
1688
+ }), event.next[0]);
2048
1689
  }
2049
- ];
2050
- const FIRE_ARROW_PARAMS = [
2051
- {
2052
- key: "arrowheadWidthRatio",
2053
- presentationTier: "advanced",
2054
- label: "Arrowhead width",
2055
- description: "Arrowhead width as a ratio of the shaft length",
2056
- type: "number",
2057
- min: .05,
2058
- max: 1,
2059
- step: .01
2060
- },
2061
- {
2062
- key: "rearLineLengthRatio",
2063
- presentationTier: "advanced",
2064
- label: "Rear line length",
2065
- description: "Length of the rear flare lines as a ratio of the head",
2066
- type: "number",
2067
- min: .05,
2068
- max: 1,
2069
- step: .01
2070
- },
2071
- {
2072
- key: "rearLineAngle",
2073
- presentationTier: "advanced",
2074
- label: "Rear line angle",
2075
- description: "Angle of the rear flare lines, in degrees",
2076
- type: "number",
2077
- min: 0,
2078
- max: 90,
2079
- step: 1
1690
+ };
1691
+ //#endregion
1692
+ //#region src/draw-rules/area21.ts
1693
+ function derive$1(points) {
1694
+ if (points.length === 2) return clonePositions([
1695
+ points[0],
1696
+ points[1],
1697
+ points[1]
1698
+ ]);
1699
+ return clonePositions(points);
1700
+ }
1701
+ const searchAreaDrawRule = {
1702
+ id: "area21:search-area",
1703
+ minimumUserPoints: 3,
1704
+ minimumPreviewPoints: 2,
1705
+ canonicalPointCount: 3,
1706
+ derive: derive$1,
1707
+ transform(event) {
1708
+ return derive$1(event.next);
2080
1709
  }
2081
- ];
2082
- const FORTIFIED_AREA_SIZE_PARAMS = [{
2083
- key: "toothSizePixels",
2084
- label: "Tooth size",
2085
- description: "Size of the fortified-area teeth, in pixels",
2086
- type: "number",
2087
- min: 5,
2088
- max: 50,
2089
- step: 1,
2090
- unit: "px"
2091
- }, {
2092
- key: "toothSize",
2093
- label: "Tooth size",
2094
- description: "Size of the fortified-area teeth, in meters",
2095
- type: "number",
2096
- min: 1,
2097
- max: 500,
2098
- step: 1,
2099
- unit: "m"
2100
- }];
2101
- const ENCIRCLEMENT_PARAMS = [
2102
- {
2103
- key: "smooth",
2104
- label: "Smooth",
2105
- description: "Round the boundary by curving it through the control points.",
2106
- type: "boolean"
2107
- },
2108
- {
2109
- key: "smoothResolution",
2110
- presentationTier: "advanced",
2111
- label: "Smooth resolution",
2112
- description: "Number of samples per segment when smooth mode is enabled.",
2113
- type: "number",
2114
- min: 2,
2115
- max: 64,
2116
- step: 1,
2117
- visibleWhen: (opts) => Boolean(opts.smooth)
2118
- },
2119
- {
2120
- key: "barbSpacingRatio",
2121
- presentationTier: "advanced",
2122
- label: "Spacing",
2123
- description: "Spacing between barbs as a ratio of the symbol radius (smaller is denser).",
2124
- type: "number",
2125
- min: .15,
2126
- max: 1.5,
2127
- step: .05
2128
- },
2129
- {
2130
- key: "barbLengthRatio",
2131
- presentationTier: "advanced",
2132
- label: "Barb length",
2133
- description: "Barb height as a ratio of the symbol radius (amplitude).",
2134
- type: "number",
2135
- min: .02,
2136
- max: .5,
2137
- step: .01
2138
- }
2139
- ];
2140
- const FORTIFIED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, ...FORTIFIED_AREA_SIZE_PARAMS];
2141
- const AREA_LABEL_PADDING_PARAM = {
2142
- key: "labelPadding",
2143
- presentationTier: "advanced",
2144
- label: "Label padding",
2145
- description: "Extra spacing between or around the area's labels, as a ratio of label height",
2146
- type: "number",
2147
- min: 0,
2148
- max: 2,
2149
- step: .05
2150
- };
2151
- const MULTILINE_AREA_LABEL_PARAM = {
2152
- key: "multilineLabel",
2153
- label: "Multiline label",
2154
- description: "Place the doctrinal label above Field T on separate centered lines",
2155
- type: "boolean"
2156
- };
2157
- const LABELED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, AREA_LABEL_PADDING_PARAM];
2158
- const PREFIXED_LABELED_AREA_PARAMS = [
2159
- ...AREA_SMOOTH_PARAMS,
2160
- MULTILINE_AREA_LABEL_PARAM,
2161
- AREA_LABEL_PADDING_PARAM
2162
- ];
2163
- const ECHELON_GLYPH_PARAMS = [
2164
- {
2165
- key: "echelon",
2166
- label: "Echelon",
2167
- description: "Field B unit-size amplifier, drawn on the boundary.",
2168
- type: "enum",
2169
- presentationTier: "primary",
2170
- semanticRole: "doctrinal",
2171
- options: ECHELON_PARAM_OPTIONS
2172
- },
2173
- {
2174
- key: "echelonSizePixels",
2175
- label: "Echelon size",
2176
- description: "Echelon glyph height in screen pixels.",
2177
- type: "number",
2178
- min: 8,
2179
- max: 48,
2180
- step: 1,
2181
- unit: "px"
2182
- },
2183
- {
2184
- key: "echelonSize",
2185
- label: "Echelon size",
2186
- description: "Echelon glyph height in meters.",
2187
- type: "number",
2188
- min: 50,
2189
- max: 2e4,
2190
- step: 50,
2191
- unit: "m"
2192
- },
2193
- {
2194
- key: "echelonPadding",
2195
- presentationTier: "advanced",
2196
- label: "Echelon padding",
2197
- description: "Gap on each side of the echelon, as a ratio of its height.",
2198
- type: "number",
2199
- min: 0,
2200
- max: 2,
2201
- step: .05
2202
- },
2203
- {
2204
- key: "echelonPosition",
2205
- presentationTier: "advanced",
2206
- label: "Echelon position",
2207
- description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
2208
- type: "number",
2209
- min: -.5,
2210
- max: .5,
2211
- step: .01
2212
- }
2213
- ];
2214
- const ECHELON_AREA_PARAMS = [
2215
- ...AREA_SMOOTH_PARAMS,
2216
- ...ECHELON_GLYPH_PARAMS,
2217
- AREA_LABEL_PADDING_PARAM
2218
- ];
2219
- const PREFIXED_ECHELON_AREA_PARAMS = [
2220
- ...AREA_SMOOTH_PARAMS,
2221
- MULTILINE_AREA_LABEL_PARAM,
2222
- ...ECHELON_GLYPH_PARAMS,
2223
- AREA_LABEL_PADDING_PARAM
2224
- ];
2225
- const AREA_DESIGNATION_AMPLIFIER = {
2226
- key: "T",
2227
- label: "Unique designation",
2228
- description: "Field T — the unit's designation, centered in the area.",
2229
- maxLength: 21
2230
- };
2231
- const AREA_HOSTILE_AMPLIFIER = {
2232
- key: "N",
2233
- label: "Hostile (ENY) marker",
2234
- description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
2235
- placeholder: "ENY",
2236
- maxLength: 3
2237
1710
  };
2238
- const AREA_TEXT_AMPLIFIERS = [
2239
- {
2240
- key: "T",
2241
- label: "Unique designation",
2242
- description: "Field T — the area's unique designation.",
2243
- maxLength: 21
2244
- },
2245
- {
2246
- key: "H",
2247
- label: "Additional information",
2248
- description: "Field H — additional free-text information, shown below the designation.",
2249
- maxLength: 20
2250
- },
2251
- {
2252
- key: "W",
2253
- label: "Effective from (DTG)",
2254
- description: "Field W — date-time group the area becomes effective.",
2255
- placeholder: "051030Z",
2256
- maxLength: 16
2257
- },
2258
- {
2259
- key: "W1",
2260
- label: "Effective to (DTG)",
2261
- description: "Field W1 — date-time group the area ceases to be effective.",
2262
- maxLength: 16
2263
- },
2264
- AREA_HOSTILE_AMPLIFIER
2265
- ];
2266
- const AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS = [AREA_DESIGNATION_AMPLIFIER, AREA_HOSTILE_AMPLIFIER];
2267
- /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
2268
- * for measures whose template omits that row but keeps the W/W1 window and
2269
- * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
2270
- * Area). */
2271
- const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "H");
2272
- /** {@link AREA_TEXT_AMPLIFIERS} reduced to just the designation (Field T) and
2273
- * hostile marker (Field N), for measures whose template carries no
2274
- * additional-information or date-time-group rows (Area, Drop Zone,
2275
- * Extraction Zone). */
2276
- const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
2277
1711
  //#endregion
2278
- //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
2279
- const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
2280
- shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
2281
- rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
2282
- smooth: false,
2283
- smoothResolution: 5
2284
- };
2285
- const AIRBORNE_ATTACK_METADATA = {
2286
- id: "airborne-attack",
2287
- name: "Airborne Attack",
2288
- description: "Airborne or aviation axis of advance.",
2289
- entity: "Maneuver Areas",
2290
- entityType: "Axis of Advance",
2291
- entitySubtype: "Airborne Attack",
2292
- value: "151401",
2293
- minCoordinates: 3,
2294
- geometry: "line",
2295
- geometryTypes: ["LineString"],
2296
- paints: {
2297
- stroke: true,
2298
- fill: "none",
2299
- text: false
2300
- },
2301
- drawRule: "Axis1",
2302
- params: ATTACK_SHAFT_PARAMS
2303
- };
1712
+ //#region src/draw-rules/rectangle.ts
2304
1713
  /**
2305
- * Generates a GeoJSON FeatureCollection for an Airborne Attack tactical symbol.
2306
- *
2307
- * The symbol is similar to Supporting Attack but features a crossover point
2308
- * between the shaft and the head (Points 1 and 2/Neck).
2309
- *
2310
- * @param coordinates - An array of GeoJSON Positions.
2311
- * @param options - Configuration options.
2312
- * @returns A GeoJSON FeatureCollection<LineString>.
1714
+ * Oriented rectangle: P1 and P2 are adjacent corners and define the first
1715
+ * edge. P3 is the next corner, constrained to the perpendicular through P2.
1716
+ * The generator derives the fourth corner.
2313
1717
  */
2314
- function createAirborneAttack(coordinates, options = {}) {
2315
- const { geometry } = processAttackGeometry(coordinates, options);
2316
- if (!geometry) return {
2317
- type: "FeatureCollection",
2318
- features: []
2319
- };
2320
- const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
2321
- const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
2322
- return {
2323
- type: "FeatureCollection",
2324
- features: [{
2325
- type: "Feature",
2326
- properties: {},
2327
- geometry: {
2328
- type: "LineString",
2329
- coordinates: [
2330
- ...shaftLeftToNeck,
2331
- geometry.headRing[3],
2332
- geometry.headRing[2],
2333
- geometry.headRing[1],
2334
- geometry.headRing[0],
2335
- geometry.headRing[5],
2336
- ...[...shaftRightToNeck].reverse()
2337
- ].map((p) => unproject(p[0], p[1]))
2338
- }
2339
- }]
2340
- };
2341
- }
2342
- const AIRBORNE_ATTACK = defineControlMeasure({
2343
- metadata: AIRBORNE_ATTACK_METADATA,
2344
- generator: createAirborneAttack,
2345
- defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
2346
- rule: axis1DrawRule,
2347
- optionHandles: variableWidthAttackOptionHandles
2348
- });
2349
- /**
2350
- * Reacts to an **input-contract** violation according to `mode`: `throw`
2351
- * raises, `warn` logs, `silent` (the default) does neither. Always returns
2352
- * `false` so a caller can `return reportValidationIssue(...)` from a guard.
2353
- *
2354
- * Governs the *input contract* only (control-point count and finiteness),
2355
- * checked once at the render seam — not geometric degeneracy, which always
2356
- * yields an empty render silently. See ADR-0014 and the `CONTEXT.md` terms.
2357
- */
2358
- function reportValidationIssue(mode, message) {
2359
- const resolved = mode ?? "silent";
2360
- if (resolved === "throw") throw new Error(message);
2361
- if (resolved === "warn") console.warn(message);
2362
- return false;
1718
+ function derive(points) {
1719
+ if (points.length < 2) return clonePositions(points);
1720
+ const p1 = clonePosition(points[0]);
1721
+ const p2 = clonePosition(points[1]);
1722
+ if (points.length === 2) return [
1723
+ p1,
1724
+ p2,
1725
+ clonePosition(p2)
1726
+ ];
1727
+ const frame = createBaselineFrame(p1, p2, {
1728
+ origin: "p2",
1729
+ normal: "right"
1730
+ });
1731
+ return [
1732
+ p1,
1733
+ p2,
1734
+ frame?.pointAtNormalDistance(frame.signedNormalDistance(points[2])) ?? clonePosition(points[2])
1735
+ ];
2363
1736
  }
2364
- /**
2365
- * Narrows a control-point array to the fixed `[PT1, PT2, PT3]` tuple the ambush
2366
- * and search-area generators require. The render seam has already validated
2367
- * that at least three valid points are present (ADR-0014), so this is a pure
2368
- * cast with no runtime guard. Used by those measures' definition adapters so
2369
- * the generators keep their precise tuple parameter (ADR-0013).
2370
- */
2371
- function asThreePoints(points) {
1737
+ function guidePoints(points) {
1738
+ const canonical = derive(points);
1739
+ if (canonical.length < 3) return canonical;
1740
+ const [c1, c2, c3] = canonical;
1741
+ if (!c1 || !c2 || !c3) return canonical;
1742
+ if (c2[0] === c3[0] && c2[1] === c3[1]) return [c1, c2];
1743
+ const p1 = project(c1[0], c1[1]);
1744
+ const p2 = project(c2[0], c2[1]);
1745
+ const p4 = vecAdd(p1, vecSub(project(c3[0], c3[1]), p2));
2372
1746
  return [
2373
- points[0],
2374
- points[1],
2375
- points[2]
1747
+ c1,
1748
+ c2,
1749
+ c3,
1750
+ unproject(p4[0], p4[1]),
1751
+ clonePosition(c1)
2376
1752
  ];
2377
1753
  }
2378
- const DEFAULT_AMBUSH_OPTIONS = {
2379
- arrowheadLengthRatio: .15,
2380
- arrowheadWidthRatio: .5,
2381
- hatchLengthRatio: .15,
2382
- hatchCount: 5,
2383
- curveDepthRatio: .25,
2384
- arcSegments: 30
2385
- };
2386
- const AMBUSH_METADATA = {
2387
- id: "ambush",
2388
- name: "Ambush",
2389
- description: "A variation of attack from concealed positions against a moving or temporarily halted enemy.",
2390
- entity: "Maneuver Lines",
2391
- entityType: "Ambush",
2392
- value: "141700",
2393
- minCoordinates: 3,
2394
- maxCoordinates: 3,
2395
- geometry: "line",
2396
- geometryTypes: ["MultiLineString"],
2397
- paints: {
2398
- stroke: true,
2399
- fill: "none",
2400
- text: false
2401
- },
2402
- drawRule: "Line29",
2403
- params: [
2404
- {
2405
- key: "arrowheadLengthRatio",
2406
- presentationTier: "advanced",
2407
- label: "Arrowhead length",
2408
- description: "Arrowhead length as a ratio of the chord span",
2409
- type: "number",
2410
- min: .01,
2411
- max: .5,
2412
- step: .01
2413
- },
2414
- {
2415
- key: "arrowheadWidthRatio",
2416
- presentationTier: "advanced",
2417
- label: "Arrowhead width",
2418
- description: "Arrowhead base width as a multiple of the arrowhead length",
2419
- type: "number",
2420
- min: .05,
2421
- max: 2,
2422
- step: .01
2423
- },
2424
- {
2425
- key: "hatchLengthRatio",
2426
- presentationTier: "advanced",
2427
- label: "Hatch length",
2428
- description: "Length of the parallel hatches as a fraction of the chord span",
2429
- type: "number",
2430
- min: .01,
2431
- max: .5,
2432
- step: .01
2433
- },
2434
- {
2435
- key: "hatchCount",
2436
- presentationTier: "advanced",
2437
- label: "Hatch count",
2438
- description: "Number of parallel hatches spaced along the curve",
2439
- type: "number",
2440
- min: 0,
2441
- max: 20,
2442
- step: 1
2443
- },
2444
- {
2445
- key: "curveDepthRatio",
2446
- presentationTier: "advanced",
2447
- label: "Curve depth",
2448
- description: "How far the back line bends toward the tip, as a fraction of the chord span",
2449
- type: "number",
2450
- min: 0,
2451
- max: 1,
2452
- step: .01
2453
- },
2454
- {
2455
- key: "arcSegments",
2456
- label: "Arc segments",
2457
- description: "Number of segments used to render the curve",
2458
- type: "number",
2459
- presentationTier: "advanced",
2460
- min: 4,
2461
- max: 96,
2462
- step: 1
1754
+ function snapToAxis(point, origin, direction) {
1755
+ const snapped = vecAdd(origin, vecScale(direction, vecDot(vecSub(project(point[0], point[1]), origin), direction)));
1756
+ return unproject(snapped[0], snapped[1]);
1757
+ }
1758
+ const rectangleDrawRule = {
1759
+ id: "rectangle",
1760
+ minimumUserPoints: 3,
1761
+ minimumPreviewPoints: 2,
1762
+ canonicalPointCount: 3,
1763
+ showGuide: true,
1764
+ guidePoints,
1765
+ derive,
1766
+ transform(event) {
1767
+ const { previous, next, activePointIndex } = event;
1768
+ if (previous.length < 3 || next.length < 3) return derive(next);
1769
+ const frame = createBaselineFrame(previous[0], previous[1], {
1770
+ origin: "p2",
1771
+ normal: "right"
1772
+ });
1773
+ if (!frame) return derive(next);
1774
+ if (activePointIndex === 0) return [
1775
+ snapToAxis(next[0], frame.p2, frame.direction),
1776
+ clonePosition(previous[1]),
1777
+ clonePosition(previous[2])
1778
+ ];
1779
+ if (activePointIndex === 1) {
1780
+ const p2 = snapToAxis(next[1], frame.p1, frame.direction);
1781
+ const delta = vecSub(project(p2[0], p2[1]), frame.p2);
1782
+ const projectedP3 = vecAdd(project(previous[2][0], previous[2][1]), delta);
1783
+ const p3 = unproject(projectedP3[0], projectedP3[1]);
1784
+ return [
1785
+ clonePosition(previous[0]),
1786
+ p2,
1787
+ p3
1788
+ ];
2463
1789
  }
2464
- ]
1790
+ if (activePointIndex === 2) {
1791
+ const p3 = frame.pointAtNormalDistance(frame.signedNormalDistance(next[2]));
1792
+ if (p3) return [
1793
+ clonePosition(previous[0]),
1794
+ clonePosition(previous[1]),
1795
+ p3
1796
+ ];
1797
+ }
1798
+ return derive(next);
1799
+ }
2465
1800
  };
2466
- const quadBezier = (t, a, b, c) => {
2467
- const it = 1 - t;
2468
- 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]];
1801
+ //#endregion
1802
+ //#region src/draw-rules/line25.ts
1803
+ /** Line25: PT. 1 is the arrow tip; PT. 2 is the rear of the symbol. */
1804
+ const line25DrawRule = {
1805
+ id: "line25",
1806
+ minimumUserPoints: 2,
1807
+ canonicalPointCount: 2,
1808
+ derive: (points) => clonePositions(points.slice(0, 2)),
1809
+ transform: (event) => clonePositions(event.next.slice(0, 2))
2469
1810
  };
1811
+ //#endregion
1812
+ //#region src/internal/angle-utils.ts
2470
1813
  /**
2471
- * Generates a GeoJSON FeatureCollection for an AMBUSH tactical symbol.
2472
- * Handles internal projection to maintain strict perpendicularity and parallel hatching.
2473
- *
2474
- * @param coordinates - [PT 1 (Target Tip), PT 2 (Back Chord End), PT 3 (Back Chord End)]
2475
- * @param options - Geometric and styling parameters
2476
- * @returns GeoJSON FeatureCollection containing LineString components
1814
+ * Wraps an angle (radians) into the half-open range (-π, π].
2477
1815
  */
2478
- function createAmbushSymbol(coordinates, options = {}) {
2479
- const { arrowheadLengthRatio, arrowheadWidthRatio, hatchLengthRatio, hatchCount, curveDepthRatio, arcSegments } = {
2480
- ...DEFAULT_AMBUSH_OPTIONS,
2481
- ...options
2482
- };
2483
- const safeArcSegments = Math.max(1, Math.floor(arcSegments));
2484
- const safeHatchCount = Math.max(0, Math.floor(hatchCount));
2485
- const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
2486
- const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
2487
- const safeHatchLengthRatio = Math.max(0, hatchLengthRatio);
2488
- const safeCurveDepthRatio = Math.max(0, curveDepthRatio);
2489
- const [p1, p2, p3] = coordinates.map((coord) => project(coord[0], coord[1]));
2490
- const mid = vecScale(vecAdd(p2, p3), .5);
2491
- const v23 = vecSub(p3, p2);
2492
- const span = vecMag(v23);
2493
- if (span < 1e-6) return {
2494
- type: "FeatureCollection",
2495
- features: []
2496
- };
2497
- const dir23 = vecNorm(v23);
2498
- const n = [-dir23[1], dir23[0]];
2499
- const dist = vecDot(vecSub(p1, mid), n);
2500
- const side = dist >= 0 ? 1 : -1;
2501
- const ctrl = vecAdd(mid, vecScale(n, side * span * safeCurveDepthRatio));
2502
- const shaftOrigin = quadBezier(.5, p2, ctrl, p3);
2503
- const adjP1 = vecAdd(shaftOrigin, vecScale(n, side * Math.max(0, Math.abs(dist) - span * safeCurveDepthRatio * .5)));
2504
- const lines = [];
2505
- lines.push([shaftOrigin, adjP1]);
2506
- const hL = span * safeArrowheadLengthRatio;
2507
- const hw = hL * safeArrowheadWidthRatio;
2508
- const hBase = vecSub(adjP1, vecScale(n, side * hL));
2509
- const perp = [-n[1], n[0]];
2510
- lines.push([
2511
- vecAdd(hBase, vecScale(perp, hw)),
2512
- adjP1,
2513
- vecSub(hBase, vecScale(perp, hw))
2514
- ]);
2515
- const curve = [];
2516
- for (let i = 0; i <= safeArcSegments; i++) {
2517
- const t = i / safeArcSegments;
2518
- curve.push(quadBezier(t, p2, ctrl, p3));
2519
- }
2520
- lines.push(curve);
2521
- const hatchLengthActual = span * safeHatchLengthRatio;
2522
- for (let i = 1; i <= safeHatchCount; i++) {
2523
- const pt = quadBezier(i / (safeHatchCount + 1), p2, ctrl, p3);
2524
- lines.push([pt, vecSub(pt, vecScale(n, side * hatchLengthActual))]);
2525
- }
2526
- return {
2527
- type: "FeatureCollection",
2528
- features: [{
2529
- type: "Feature",
2530
- properties: {},
2531
- geometry: {
2532
- type: "MultiLineString",
2533
- coordinates: lines.map((line) => line.map((c) => unproject(c[0], c[1])))
2534
- }
2535
- }]
2536
- };
2537
- }
2538
- const AMBUSH = defineControlMeasure({
2539
- metadata: AMBUSH_METADATA,
2540
- generator: (controlPoints, options) => createAmbushSymbol(asThreePoints(controlPoints), options),
2541
- defaultOptions: DEFAULT_AMBUSH_OPTIONS,
2542
- rule: ambushDrawRule
2543
- });
2544
- //#endregion
2545
- //#region src/internal/gapped-line.ts
2546
- /**
2547
- * Pushes the arc-length gap `[g0, g1]` onto `gaps`, normalizing it onto
2548
- * `[0, total)` and splitting it into two intervals when it straddles a ring
2549
- * seam ({@link buildGappedLine} walks a flat array and does not wrap on its
2550
- * own). Use this for closed rings; open polylines can push {@link ArcGap}s
2551
- * directly.
2552
- */
2553
- function pushWrappedGap(gaps, g0, g1, total) {
2554
- if (g1 - g0 >= total) {
2555
- gaps.push({
2556
- g0: 0,
2557
- g1: total
2558
- });
2559
- return;
2560
- }
2561
- const lo = (g0 % total + total) % total;
2562
- const hi = (g1 % total + total) % total;
2563
- if (lo <= hi) gaps.push({
2564
- g0: lo,
2565
- g1: hi
2566
- });
2567
- else {
2568
- gaps.push({
2569
- g0: lo,
2570
- g1: total
2571
- });
2572
- gaps.push({
2573
- g0: 0,
2574
- g1: hi
2575
- });
2576
- }
2577
- }
2578
- /** Whether the global arc position `arc` falls inside any of `gaps` (each
2579
- * already normalized onto `[0, total)` by {@link pushWrappedGap}, so a plain
2580
- * per-gap interval test suffices). Shared by the boundary-decoration measures
2581
- * that suppress a glyph/tic/barb sitting on top of a masking gap. */
2582
- function arcInGaps(arc, gaps) {
2583
- return gaps.some((g) => arc >= g.g0 && arc <= g.g1);
2584
- }
2585
- /** Appends `position` unless it coincides with the buffer's current tail. */
2586
- function appendUnique(buffer, position) {
2587
- const tail = buffer[buffer.length - 1];
2588
- if (tail && Math.abs(tail[0] - position[0]) < 1e-9 && Math.abs(tail[1] - position[1]) < 1e-9) return;
2589
- buffer.push(position);
2590
- }
2591
- /**
2592
- * Builds the polyline as MultiLineString rings, splitting it with a gap at each
2593
- * echelon position. `gaps` holds global arc-length intervals (distance from the
2594
- * start of the whole polyline) to carve out — a single gap can span several
2595
- * short segments, which matters when the line has been smoothed into many
2596
- * samples. Works for both open polylines and closed rings (pass a vertex array
2597
- * whose first and last positions coincide).
2598
- */
2599
- function buildGappedLine(verts, gaps) {
2600
- const parts = [];
2601
- let buffer = [];
2602
- const flush = () => {
2603
- if (buffer.length >= 2) parts.push(buffer);
2604
- buffer = [];
2605
- };
2606
- if (verts.length === 0) return parts;
2607
- const merged = [];
2608
- for (const g of [...gaps].sort((x, y) => x.g0 - y.g0)) {
2609
- const last = merged[merged.length - 1];
2610
- if (last && g.g0 <= last.g1) last.g1 = Math.max(last.g1, g.g1);
2611
- else merged.push({ ...g });
2612
- }
2613
- appendUnique(buffer, unproject(verts[0][0], verts[0][1]));
2614
- let dist = 0;
2615
- for (let i = 0; i < verts.length - 1; i++) {
2616
- const a = verts[i];
2617
- const b = verts[i + 1];
2618
- const seg = vecSub(b, a);
2619
- const L = vecMag(seg);
2620
- if (L < 1e-6) continue;
2621
- const segStart = dist;
2622
- const segEnd = dist + L;
2623
- const along = vecNorm(seg);
2624
- const pointAt = (globalD) => {
2625
- const q = vecAdd(a, vecScale(along, globalD - segStart));
2626
- return unproject(q[0], q[1]);
2627
- };
2628
- for (const g of merged) {
2629
- if (g.g1 <= segStart || g.g0 >= segEnd) continue;
2630
- const g0 = Math.max(segStart, g.g0);
2631
- const g1 = Math.min(segEnd, g.g1);
2632
- if (g1 <= g0) continue;
2633
- appendUnique(buffer, pointAt(g0));
2634
- flush();
2635
- buffer.push(pointAt(g1));
2636
- }
2637
- appendUnique(buffer, unproject(b[0], b[1]));
2638
- dist = segEnd;
2639
- }
2640
- flush();
2641
- return parts;
2642
- }
2643
- //#endregion
2644
- //#region src/style.ts
2645
- /**
2646
- * Per-feature style hint pinning a filled part to a solid interior. Attach to
2647
- * a generator's intrinsic doctrinal accents — arrowheads, teeth, echelon
2648
- * glyphs, barbs, blades — so a patterned (`hatch`, `dots`, …) fill set at the graphicsStyle
2649
- * or measure layer never bleeds into a small silhouette and ruins its
2650
- * legibility. The renderer only reads style hints, so one shared instance is
2651
- * safe to reuse across every accent feature.
2652
- */
2653
- const SOLID_ACCENT_FILL = { fillPattern: "solid" };
2654
- /**
2655
- * Ultimate fallback for the symbol color when no `color` or per-channel
2656
- * override is supplied at any layer. Keeps a zero-config render monocolor
2657
- * black and guarantees filled parts still render filled. See ADR-0011.
2658
- */
2659
- const DEFAULT_SYMBOL_COLOR = "#000000";
2660
- //#endregion
2661
- //#region src/portrayal.ts
2662
- const DEFAULT_STROKE_WIDTH_CSS_PIXELS = 2;
2663
- const DEFAULT_STROKE_DASH_CSS_PIXELS = Object.freeze([]);
2664
- const DEFAULT_LINE_CAP = "round";
2665
- const DEFAULT_LINE_JOIN = "round";
2666
- const DEFAULT_LABEL_HEIGHT_CSS_PIXELS = 14;
2667
- const DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS = {
2668
- min: 8,
2669
- max: 24
2670
- };
2671
- /** Shared absent-value portrayal defaults; render output remains sparse. */
2672
- const DEFAULT_PORTRAYAL = Object.freeze({
2673
- symbolColor: DEFAULT_SYMBOL_COLOR,
2674
- strokeWidthCssPixels: 2,
2675
- strokeDashCssPixels: DEFAULT_STROKE_DASH_CSS_PIXELS,
2676
- lineCap: DEFAULT_LINE_CAP,
2677
- lineJoin: DEFAULT_LINE_JOIN,
2678
- labelHeightCssPixels: 14
2679
- });
2680
- //#endregion
2681
- //#region src/internal/angle-utils.ts
2682
- /**
2683
- * Wraps an angle (radians) into the half-open range (-π, π].
2684
- */
2685
- function normalizeRadians(angle) {
2686
- let normalized = angle % (Math.PI * 2);
2687
- if (normalized > Math.PI) normalized -= Math.PI * 2;
2688
- if (normalized <= -Math.PI) normalized += Math.PI * 2;
2689
- return normalized;
2690
- }
2691
- /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
2692
- function normalizeDegrees(degrees) {
2693
- return (degrees % 360 + 360) % 360;
2694
- }
2695
- /**
2696
- * Flips a text rotation by π when it would otherwise read upside-down, keeping
2697
- * the baseline pointing left-to-right (within ±90° of horizontal).
2698
- */
2699
- function keepTextLeftToRight(rotation) {
2700
- const normalized = normalizeRadians(rotation);
2701
- if (normalized > Math.PI / 2) return normalized - Math.PI;
2702
- if (normalized < -Math.PI / 2) return normalized + Math.PI;
2703
- return normalized;
2704
- }
2705
- //#endregion
2706
- //#region src/internal/sketch.ts
2707
- /**
2708
- * Projects `coordinates` and derives the arrow's tip, heading and total length.
2709
- * Returns `null` for degenerate input (fewer than two points, a zero-length
2710
- * path, or a zero-length final segment) so the caller can decide what to emit.
2711
- */
2712
- function arrowAxis(coordinates) {
2713
- if (coordinates.length < 2) return null;
2714
- const pts = coordinates.map((c) => project(c[0], c[1]));
2715
- let pathLength = 0;
2716
- for (let i = 0; i < pts.length - 1; i++) pathLength += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
2717
- const tip = pts[pts.length - 1];
2718
- const prev = pts[pts.length - 2];
2719
- const hx = tip[0] - prev[0];
2720
- const hy = tip[1] - prev[1];
2721
- const segLength = Math.hypot(hx, hy);
2722
- if (segLength === 0 || pathLength === 0) return null;
2723
- const dir = [hx / segLength, hy / segLength];
2724
- const perp = [-dir[1], dir[0]];
2725
- return {
2726
- pts,
2727
- pathLength,
2728
- tip,
2729
- dir,
2730
- perp,
2731
- segLength
1816
+ function normalizeRadians(angle) {
1817
+ let normalized = angle % (Math.PI * 2);
1818
+ if (normalized > Math.PI) normalized -= Math.PI * 2;
1819
+ if (normalized <= -Math.PI) normalized += Math.PI * 2;
1820
+ return normalized;
1821
+ }
1822
+ /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
1823
+ function normalizeDegrees(degrees) {
1824
+ return (degrees % 360 + 360) % 360;
1825
+ }
1826
+ /**
1827
+ * Flips a text rotation by π when it would otherwise read upside-down, keeping
1828
+ * the baseline pointing left-to-right (within ±90° of horizontal).
1829
+ */
1830
+ function keepTextLeftToRight(rotation) {
1831
+ const normalized = normalizeRadians(rotation);
1832
+ if (normalized > Math.PI / 2) return normalized - Math.PI;
1833
+ if (normalized < -Math.PI / 2) return normalized + Math.PI;
1834
+ return normalized;
1835
+ }
1836
+ //#endregion
1837
+ //#region src/internal/sketch.ts
1838
+ /**
1839
+ * Projects `coordinates` and derives the arrow's tip, heading and total length.
1840
+ * Returns `null` for degenerate input (fewer than two points, a zero-length
1841
+ * path, or a zero-length final segment) so the caller can decide what to emit.
1842
+ */
1843
+ function arrowAxis(coordinates) {
1844
+ if (coordinates.length < 2) return null;
1845
+ const pts = coordinates.map((c) => project(c[0], c[1]));
1846
+ let pathLength = 0;
1847
+ for (let i = 0; i < pts.length - 1; i++) pathLength += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]);
1848
+ const tip = pts[pts.length - 1];
1849
+ const prev = pts[pts.length - 2];
1850
+ const hx = tip[0] - prev[0];
1851
+ const hy = tip[1] - prev[1];
1852
+ const segLength = Math.hypot(hx, hy);
1853
+ if (segLength === 0 || pathLength === 0) return null;
1854
+ const dir = [hx / segLength, hy / segLength];
1855
+ const perp = [-dir[1], dir[0]];
1856
+ return {
1857
+ pts,
1858
+ pathLength,
1859
+ tip,
1860
+ dir,
1861
+ perp,
1862
+ segLength
2732
1863
  };
2733
1864
  }
2734
1865
  /**
@@ -3209,286 +2340,1699 @@ const DEFAULT_LABEL_KNOCKOUT_PADDING = .3;
3209
2340
  const HOSTILE_LINE_AMPLIFIER = {
3210
2341
  key: "N",
3211
2342
  label: "Hostile (ENY) marker",
3212
- description: "Field N — literal \"ENY\" marker beyond both ends of the line.",
2343
+ description: "Field N — literal \"ENY\" marker beyond both ends of the line.",
2344
+ placeholder: "ENY",
2345
+ maxLength: 3
2346
+ };
2347
+ /** Label-padding control shared by label-bearing graphics. */
2348
+ const LABEL_PADDING_PARAM = {
2349
+ key: "labelPadding",
2350
+ presentationTier: "advanced",
2351
+ label: "Label padding",
2352
+ description: "Extra clearance around labels, as a ratio of the label height",
2353
+ type: "number",
2354
+ min: 0,
2355
+ max: 2,
2356
+ step: .05
2357
+ };
2358
+ /**
2359
+ * Resolves {@link LabelSizeOptions} into the single text-size property the
2360
+ * label feature carries (ADR-0028): `textSizePixels` when screen-anchored
2361
+ * (`labelSizePixels` present — pixels win over a stray `labelSize`),
2362
+ * `textSizeMeters` when ground-anchored (`labelSize` alone), or neither, in
2363
+ * which case adapters render their engine default, screen-fixed.
2364
+ */
2365
+ function labelSizeProps(options) {
2366
+ const { labelSize, labelSizePixels } = options;
2367
+ if (labelSizePixels !== void 0) return { textSizePixels: labelSizePixels };
2368
+ if (labelSize !== void 0) return { textSizeMeters: labelSize };
2369
+ return {};
2370
+ }
2371
+ /** Pushes a `part: "label"` Point feature at projected `point`. */
2372
+ function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField, labelPlacementKey) {
2373
+ out.push({
2374
+ type: "Feature",
2375
+ properties: {
2376
+ part: "label",
2377
+ text,
2378
+ rotation,
2379
+ ...textAnchor ? { textAnchor } : {},
2380
+ ...amplifierField ? { amplifierField } : {},
2381
+ ...labelPlacementKey ? { labelPlacementKey } : {},
2382
+ ...sizeProps
2383
+ },
2384
+ geometry: {
2385
+ type: "Point",
2386
+ coordinates: unproject(point[0], point[1])
2387
+ }
2388
+ });
2389
+ }
2390
+ /** Ratio of resolved label height (`labelSize`, or `labelSizePixels × metersPerPixel`) used as end-label clearance. */
2391
+ const END_LABEL_CLEARANCE_RATIO = .3;
2392
+ /** Ratio of resolved label height used to inset "above" end labels from the tip. */
2393
+ const ABOVE_LABEL_INSET_RATIO = .3;
2394
+ /** Ratio of resolved label height used as the "above" end labels' perpendicular offset. */
2395
+ const ABOVE_LABEL_OFFSET_RATIO = .6;
2396
+ function textHeight(options) {
2397
+ const { labelSize, labelSizePixels, metersPerPixel } = options;
2398
+ if (labelSizePixels !== void 0) return metersPerPixel !== void 0 && metersPerPixel > 0 ? labelSizePixels * metersPerPixel : void 0;
2399
+ return labelSize;
2400
+ }
2401
+ /**
2402
+ * Label offset in meters: `ratio` × the label's resolved height (`labelSize`
2403
+ * meters, or `labelSizePixels × metersPerPixel` when screen-anchored), or
2404
+ * `fallbackMeters` when no size resolves (e.g. no map resolution yet). The fallback is small
2405
+ * relative to typical control measure spans — the same order of magnitude as
2406
+ * Boundary's own smallest label offsets — so a fixed label still reads as "just
2407
+ * clear of the line" rather than crowding it or floating away. Callers supply
2408
+ * their own ratio (end-label clearance, "above" inset/offset, center-group
2409
+ * offset) but share this single resolution+fallback rule.
2410
+ */
2411
+ function resolveLabelOffsetMeters(options, ratio, fallbackMeters = 50) {
2412
+ const h = textHeight(options);
2413
+ return h !== void 0 ? ratio * h : fallbackMeters;
2414
+ }
2415
+ /**
2416
+ * Places one label just beyond each end of `verts` (projected meters), along
2417
+ * the outward direction of that end's segment, reading along the line
2418
+ * ({@link labelRotationAlong} is sign-independent, so the outward-pointing
2419
+ * `along` reads correctly). `textForEnd` supplies each end's text — a shared
2420
+ * string, or a function keyed by which end; an empty string skips that end.
2421
+ *
2422
+ * `textAnchor` is chosen so the text grows AWAY from the line: adapters flip
2423
+ * a label's rendered baseline (via {@link keepTextLeftToRight}) whenever its
2424
+ * direction points leftward, and that flip happens exactly when `along[0] <
2425
+ * 0` (same quadrant test `labelRotationAlong` relies on) — mirroring
2426
+ * Boundary's `vecDot` sign trick, "start" anchors the text at the point when
2427
+ * there is no flip, "end" when there is, so the anchored edge always sits at
2428
+ * the point and the text body always extends outward.
2429
+ */
2430
+ function pushEndLabels(out, verts, textForEnd, options = {}) {
2431
+ const frames = endFrames(verts);
2432
+ const sizeProps = labelSizeProps(options);
2433
+ const clearance = resolveLabelOffsetMeters(options, END_LABEL_CLEARANCE_RATIO + Math.max(0, options.labelPadding ?? 0));
2434
+ for (const end of ["start", "end"]) {
2435
+ const frame = frames[end];
2436
+ if (!frame) continue;
2437
+ const text = typeof textForEnd === "string" ? textForEnd : textForEnd(end);
2438
+ if (text.length === 0) continue;
2439
+ pushLabel(out, vecAdd(frame.point, vecScale(frame.along, clearance)), text, labelRotationAlong(frame.along), sizeProps, frame.along[0] >= 0 ? "start" : "end");
2440
+ }
2441
+ }
2442
+ /**
2443
+ * Approximate sans-serif character width/height ratio, used to estimate a
2444
+ * label's rendered text width from its character count when no live text
2445
+ * measurement is available at generator time.
2446
+ */
2447
+ const LABEL_CHAR_ASPECT_RATIO = .6;
2448
+ /**
2449
+ * Half a single glyph's width, as a ratio of its height — the base boundary
2450
+ * gap a measure reserves for one intrinsic capital letter (Retain's R,
2451
+ * Contain's C). Wider than half `LABEL_CHAR_ASPECT_RATIO` because these
2452
+ * glyphs are stamped alone rather than averaged over a word.
2453
+ */
2454
+ const LABEL_HALF_GLYPH_RATIO = .45;
2455
+ /** Estimated rendered width of `text` given its resolved label height in meters. */
2456
+ function estimatedTextWidth(text, textHeightMeters) {
2457
+ return text.length * LABEL_CHAR_ASPECT_RATIO * textHeightMeters;
2458
+ }
2459
+ /** Resolve the label's final display size, then measure and convert it to construction metres. */
2460
+ function resolveTextMetrics(text, options, context, style = "regular", fallbackHeightMeters = 50) {
2461
+ const legacyHeight = resolveLabelOffsetMeters(options, 1, fallbackHeightMeters);
2462
+ const constructionScale = context?.constructionMetersPerCssPixel;
2463
+ if (!(constructionScale !== void 0 && constructionScale > 0)) return {
2464
+ width: estimatedTextWidth(text, legacyHeight),
2465
+ height: legacyHeight
2466
+ };
2467
+ let sizeCssPixels;
2468
+ if (options.labelSizePixels !== void 0) sizeCssPixels = options.labelSizePixels;
2469
+ else if (options.labelSize !== void 0) {
2470
+ const requested = options.labelSize / constructionScale;
2471
+ const requestedBand = context?.labelSizeClampCssPixels;
2472
+ const band = requestedBand && Number.isFinite(requestedBand.min) && requestedBand.min >= 0 && Number.isFinite(requestedBand.max) && requestedBand.max >= 0 ? requestedBand : DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS;
2473
+ const min = Math.min(band.min, band.max);
2474
+ const max = Math.max(band.min, band.max);
2475
+ sizeCssPixels = Math.min(max, Math.max(min, requested));
2476
+ } else sizeCssPixels = 14;
2477
+ const fallbackHeight = sizeCssPixels * constructionScale;
2478
+ const measured = context?.measureText?.({
2479
+ text,
2480
+ style,
2481
+ sizeCssPixels
2482
+ });
2483
+ if (!measured || !Number.isFinite(measured.widthCssPixels) || measured.widthCssPixels < 0 || !Number.isFinite(measured.heightCssPixels) || measured.heightCssPixels < 0) return {
2484
+ width: estimatedTextWidth(text, fallbackHeight),
2485
+ height: fallbackHeight
2486
+ };
2487
+ return {
2488
+ width: measured.widthCssPixels * constructionScale,
2489
+ height: measured.heightCssPixels * constructionScale
2490
+ };
2491
+ }
2492
+ /**
2493
+ * Half-length of the knockout required for a label along `direction`.
2494
+ *
2495
+ * The text box itself is always cleared; `labelPadding` adds symmetric clearance expressed in
2496
+ * resolved label heights. Keeping this calculation here gives every generator the same public
2497
+ * label-clearance interface while hiding font measurement and rotated-box projection details.
2498
+ */
2499
+ function labelKnockoutHalfExtent(text, direction, rotation, options, context, defaultPadding = 0) {
2500
+ const metrics = resolveTextMetrics(text, options, context);
2501
+ const padding = Math.max(0, options.labelPadding ?? defaultPadding) * metrics.height;
2502
+ return labelHalfExtent(direction, metrics.width, metrics.height, rotation) + padding;
2503
+ }
2504
+ /**
2505
+ * {@link labelKnockoutHalfExtent} expressed as a fraction of an arc's angular
2506
+ * sweep, for generators that carve their knockout in arc-progress space rather
2507
+ * than in meters. Clamped to `0.5` so the gap can never exceed the arc.
2508
+ */
2509
+ function arcKnockoutHalfFraction(halfExtentMeters, radius, sweepAngle) {
2510
+ const arcLength = radius * Math.abs(sweepAngle);
2511
+ if (!(arcLength > 1e-6)) return .5;
2512
+ return Math.min(.5, Math.max(0, halfExtentMeters) / arcLength);
2513
+ }
2514
+ /** The end-label text for a phase-line naming, or `""` when unnamed. */
2515
+ function phaseLineLabelText(name, includePrefix) {
2516
+ if (!name) return "";
2517
+ return includePrefix ? `PL ${name}` : name;
2518
+ }
2519
+ /**
2520
+ * Places a Field N hostile marker beyond each end of `verts`, following the
2521
+ * same tangent, rotation, anchor, size, and padding conventions as
2522
+ * {@link pushEndLabels}. When another beyond-tip label is present, its
2523
+ * estimated width is added so the hostile marker remains outermost.
2524
+ */
2525
+ function pushHostileEndLabels(out, verts, hostileText, options = {}, occupiedTextForEnd = "", context) {
2526
+ if (!hostileText) return;
2527
+ const frames = endFrames(verts);
2528
+ const sizeProps = labelSizeProps(options);
2529
+ for (const end of ["start", "end"]) {
2530
+ const frame = frames[end];
2531
+ if (!frame) continue;
2532
+ const occupiedText = typeof occupiedTextForEnd === "string" ? occupiedTextForEnd : occupiedTextForEnd(end);
2533
+ const metrics = resolveTextMetrics(occupiedText || hostileText, options, context);
2534
+ const clearance = (END_LABEL_CLEARANCE_RATIO + Math.max(0, options.labelPadding ?? 0)) * metrics.height;
2535
+ const distance = clearance + (occupiedText ? metrics.width + clearance : 0);
2536
+ pushLabel(out, vecAdd(frame.point, vecScale(frame.along, distance)), hostileText, labelRotationAlong(frame.along), sizeProps, frame.along[0] >= 0 ? "start" : "end", "N", `label:N:${end}`);
2537
+ }
2538
+ }
2539
+ /**
2540
+ * Resolves the {@link AboveEndFrame} at each tip of `verts` (projected meters):
2541
+ * a base point inset from the tip toward the interior, the segment's own
2542
+ * natural `perp` (so "above" = `+perp` reads the same at both ends), the
2543
+ * one-row perpendicular `offset`, the along-line `rotation`, and the
2544
+ * interior-growing `textAnchor`. Shared by {@link pushEndLabelsAbove} (one
2545
+ * label per tip, at `base + perp·offset`) and generic-c2-line's dual
2546
+ * above/below amplifier labels (`base ± perp·offset`), so both build on the
2547
+ * same tangent/inset/anchor math instead of re-deriving it.
2548
+ */
2549
+ function aboveEndFrames(verts, options) {
2550
+ const tangents = endTangents(verts);
2551
+ const inset = resolveLabelOffsetMeters(options, ABOVE_LABEL_INSET_RATIO);
2552
+ const offset = resolveLabelOffsetMeters(options, ABOVE_LABEL_OFFSET_RATIO + Math.max(0, options.labelPadding ?? 0));
2553
+ const frames = {};
2554
+ for (const end of ["start", "end"]) {
2555
+ const tangent = tangents[end];
2556
+ if (!tangent) continue;
2557
+ frames[end] = {
2558
+ base: vecAdd(tangent.point, vecScale(tangent.interior, inset)),
2559
+ perp: tangent.perp,
2560
+ offset,
2561
+ rotation: labelRotationAlong(tangent.interior),
2562
+ textAnchor: tangent.outwardX >= 0 ? "end" : "start"
2563
+ };
2564
+ }
2565
+ return frames;
2566
+ }
2567
+ /**
2568
+ * Places one label above each end of `verts` (projected meters), inset
2569
+ * slightly from the tip and anchored toward the line's interior — the MIL-
2570
+ * STD-2525E template convention for fixed end labels like "LL"/"HOL"/"BHL"
2571
+ * (as opposed to Phase Line's "beyond the tip" placement, see
2572
+ * {@link pushEndLabels}). Placement comes from {@link aboveEndFrames}, which
2573
+ * generic-c2-line's H/T-above + W/W1-below labels share. `textForEnd` supplies
2574
+ * each end's text — a shared string, or a function keyed by which end; an empty
2575
+ * string skips that end.
2576
+ */
2577
+ function pushEndLabelsAbove(out, verts, textForEnd, options = {}) {
2578
+ const frames = aboveEndFrames(verts, options);
2579
+ const sizeProps = labelSizeProps(options);
2580
+ for (const end of ["start", "end"]) {
2581
+ const frame = frames[end];
2582
+ if (!frame) continue;
2583
+ const text = typeof textForEnd === "string" ? textForEnd : textForEnd(end);
2584
+ if (text.length === 0) continue;
2585
+ pushLabel(out, vecAdd(frame.base, vecScale(frame.perp, frame.offset)), text, frame.rotation, sizeProps, frame.textAnchor);
2586
+ }
2587
+ }
2588
+ /**
2589
+ * Convenience for measures that are just a polyline with a fixed label at
2590
+ * each end: builds the LineString through `positions` plus its end labels
2591
+ * (via {@link pushEndLabels} or {@link pushEndLabelsAbove}, per `placement`)
2592
+ * as a complete FeatureCollection.
2593
+ */
2594
+ function fixedLabelLineFeatures(positions, options, textForEnd, hostileText, context) {
2595
+ const { part, placement = "beyond", phaseLineName, includePrefix = true, lineStyle, smooth, smoothResolution, ...sizeOptions } = options;
2596
+ const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
2597
+ smooth,
2598
+ smoothResolution
2599
+ }, 12);
2600
+ const lineCoordinates = smooth ? verts.map((v) => unproject(v[0], v[1])) : positions;
2601
+ const features = [{
2602
+ type: "Feature",
2603
+ properties: lineStyle === void 0 ? { part } : {
2604
+ part,
2605
+ style: lineStyle
2606
+ },
2607
+ geometry: {
2608
+ type: "LineString",
2609
+ coordinates: lineCoordinates
2610
+ }
2611
+ }];
2612
+ const labelFeatures = [];
2613
+ if (placement === "above") pushEndLabelsAbove(labelFeatures, verts, textForEnd, sizeOptions);
2614
+ else pushEndLabels(labelFeatures, verts, textForEnd, sizeOptions);
2615
+ const nameText = phaseLineLabelText(phaseLineName?.trim(), includePrefix);
2616
+ if (nameText) pushEndLabels(labelFeatures, verts, nameText, sizeOptions);
2617
+ pushHostileEndLabels(labelFeatures, verts, hostileText, sizeOptions, nameText || (placement === "beyond" ? textForEnd : ""), context);
2618
+ features.push(...labelFeatures);
2619
+ return {
2620
+ type: "FeatureCollection",
2621
+ features
2622
+ };
2623
+ }
2624
+ //#endregion
2625
+ //#region src/generators/cm34-mission-tasks/shared.ts
2626
+ /**
2627
+ * Builds an open chevron arrowhead (left → tip → right) as unprojected
2628
+ * coordinates. The tip sits at `tip`, pointing along `dir`, with the base set
2629
+ * back by `length` and spanning `width` perpendicular to the shaft. Mirrors the
2630
+ * `createArrowHead` helper used by the attack-family generators.
2631
+ */
2632
+ function createArrowHead$2(tip, dir, length, width) {
2633
+ const norm = [-dir[1], dir[0]];
2634
+ const base = vecSub(tip, vecScale(dir, length));
2635
+ const left = vecAdd(base, vecScale(norm, width / 2));
2636
+ const right = vecSub(base, vecScale(norm, width / 2));
2637
+ return [
2638
+ unproject(left[0], left[1]),
2639
+ unproject(tip[0], tip[1]),
2640
+ unproject(right[0], right[1])
2641
+ ];
2642
+ }
2643
+ /**
2644
+ * Builds the glyph label as a Point feature, forwarding the resolved size
2645
+ * anchor (`textSizeMeters`/`textSizePixels`, ADR-0028) to adapters for
2646
+ * styling. `point` is in projected meters; the rotation must already be in
2647
+ * the generator's feature-rotation convention. Shared by every mission-task
2648
+ * measure that centers a glyph on its symbol.
2649
+ */
2650
+ function createLabelFeature(point, text, rotation, labelSize = {}) {
2651
+ return {
2652
+ type: "Feature",
2653
+ properties: {
2654
+ part: "label",
2655
+ labelPlacement: false,
2656
+ text,
2657
+ rotation,
2658
+ ...labelSizeProps(labelSize)
2659
+ },
2660
+ geometry: {
2661
+ type: "Point",
2662
+ coordinates: unproject(point[0], point[1])
2663
+ }
2664
+ };
2665
+ }
2666
+ /**
2667
+ * Shared scaffold for the "bracket" mission-task symbols (breach, bypass,
2668
+ * canalize). Owns the three-sided bracket: a front opening (PT. 1 / PT. 2), a
2669
+ * rear line parallel to it through PT. 3, and a glyph centered on the rear line.
2670
+ * Each measure supplies only its end caps and label glyph via
2671
+ * {@link BracketSymbolConfig}.
2672
+ *
2673
+ * Input Points:
2674
+ * - PT. 1: Front opening endpoint (top/left)
2675
+ * - PT. 2: Front opening endpoint (bottom/right) - defines height with PT. 1
2676
+ * - PT. 3: Rear point - defines length and orientation
2677
+ *
2678
+ * Geometric Construction:
2679
+ * 1. PT. 1 and PT. 2 define the front opening (facing enemy/objective)
2680
+ * 2. A parallel line is projected through PT. 3 with identical height
2681
+ * 3. LineString follows: PT. 1 → Rear Top → Rear Bottom → PT. 2
2682
+ * 4. Measure-specific end caps at PT. 1 and PT. 2
2683
+ * 5. Label point at midpoint of rear line (styled with OpenLayers Text)
2684
+ *
2685
+ * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
2686
+ * @param config - Per-measure end caps, label glyph, and sizing
2687
+ * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
2688
+ */
2689
+ function createBracketSymbol(coordinates, config, context) {
2690
+ const [c1, c2, c3] = coordinates;
2691
+ const p1 = project(c1[0], c1[1]);
2692
+ const p2 = project(c2[0], c2[1]);
2693
+ const p3 = project(c3[0], c3[1]);
2694
+ const vFront = vecSub(p2, p1);
2695
+ const frontLength = vecMag(vFront);
2696
+ if (frontLength < 1e-6) return {
2697
+ type: "FeatureCollection",
2698
+ features: []
2699
+ };
2700
+ const dirFront = vecNorm(vFront);
2701
+ const frontMidpoint = vecScale(vecAdd(p1, p2), .5);
2702
+ const vToRear = vecSub(p3, frontMidpoint);
2703
+ const perpDir = [-dirFront[1], dirFront[0]];
2704
+ const perpDistance = vToRear[0] * perpDir[0] + vToRear[1] * perpDir[1];
2705
+ const halfFront = vecScale(dirFront, frontLength / 2);
2706
+ const rearMidpoint = vecAdd(frontMidpoint, vecScale(perpDir, perpDistance));
2707
+ const rearTop = vecSub(rearMidpoint, halfFront);
2708
+ const rearBottom = vecAdd(rearMidpoint, halfFront);
2709
+ const rearLineRotation = Math.atan2(dirFront[1], dirFront[0]);
2710
+ const renderedLabelRotation = Math.PI - (rearLineRotation - Math.PI / 2);
2711
+ const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
2712
+ const gapHalf = Math.min(frontLength / 2, labelKnockoutHalfExtent(config.labelText, dirFront, labelRotation, config.labelOptions ?? {}, context));
2713
+ const gapPt1 = vecAdd(rearMidpoint, vecScale(dirFront, -gapHalf));
2714
+ const gapPt2 = vecAdd(rearMidpoint, vecScale(dirFront, gapHalf));
2715
+ const [topCap, bottomCap] = config.buildEndCaps({
2716
+ p1,
2717
+ p2,
2718
+ dirFront,
2719
+ perpDir,
2720
+ frontLength,
2721
+ rearTop
2722
+ });
2723
+ return {
2724
+ type: "FeatureCollection",
2725
+ features: [{
2726
+ type: "Feature",
2727
+ properties: { part: config.part },
2728
+ geometry: {
2729
+ type: "MultiLineString",
2730
+ coordinates: [
2731
+ topCap,
2732
+ [
2733
+ unproject(p1[0], p1[1]),
2734
+ unproject(rearTop[0], rearTop[1]),
2735
+ unproject(gapPt1[0], gapPt1[1])
2736
+ ],
2737
+ [
2738
+ unproject(gapPt2[0], gapPt2[1]),
2739
+ unproject(rearBottom[0], rearBottom[1]),
2740
+ unproject(p2[0], p2[1])
2741
+ ],
2742
+ bottomCap
2743
+ ]
2744
+ }
2745
+ }, createLabelFeature(rearMidpoint, config.labelText, labelRotation, config.labelOptions)]
2746
+ };
2747
+ }
2748
+ /** The single sized dimension every cross-symbol mission task shares. */
2749
+ const CROSS_TASK_SIZE_PAIR = {
2750
+ id: "size",
2751
+ label: "Size",
2752
+ pixels: "sizePixels",
2753
+ meters: "sizeMeters",
2754
+ defaultUnit: "px",
2755
+ rendering: "geometry",
2756
+ fallbackMeters: 1e3
2757
+ };
2758
+ const DEFAULT_CROSS_TASK_OPTIONS = {
2759
+ sizePixels: 80,
2760
+ rotation: 0,
2761
+ crossAngle: 2 * Math.atan(.55) * 180 / Math.PI,
2762
+ labelPadding: DEFAULT_LABEL_KNOCKOUT_PADDING
2763
+ };
2764
+ const CROSS_TASK_PARAMS = [
2765
+ {
2766
+ key: "crossAngle",
2767
+ presentationTier: "advanced",
2768
+ label: "Cross angle",
2769
+ description: "Opening angle between the two right-hand arms. Changes height while preserving width.",
2770
+ type: "number",
2771
+ min: 10,
2772
+ max: 150,
2773
+ step: 1,
2774
+ unit: "°"
2775
+ },
2776
+ {
2777
+ key: "sizePixels",
2778
+ label: "Size",
2779
+ description: "Width of the complete symbol in screen pixels.",
2780
+ type: "number",
2781
+ min: 16,
2782
+ max: 400,
2783
+ step: 1,
2784
+ unit: "px"
2785
+ },
2786
+ {
2787
+ key: "labelPadding",
2788
+ presentationTier: "advanced",
2789
+ label: "Label padding",
2790
+ description: "Extra clearance around the doctrinal letter, as a ratio of its resolved height.",
2791
+ type: "number",
2792
+ min: 0,
2793
+ max: 2,
2794
+ step: .05
2795
+ },
2796
+ {
2797
+ key: "sizeMeters",
2798
+ label: "Size",
2799
+ description: "Overall width of the static symbol.",
2800
+ type: "number",
2801
+ min: 10,
2802
+ max: 1e5,
2803
+ step: 10,
2804
+ unit: "m"
2805
+ },
2806
+ {
2807
+ key: "rotation",
2808
+ label: "Rotation",
2809
+ description: "Clockwise rotation of the static symbol.",
2810
+ type: "number",
2811
+ min: 0,
2812
+ max: 360,
2813
+ step: 1,
2814
+ unit: "°"
2815
+ }
2816
+ ];
2817
+ function transformCrossTaskOptions(options, { scale, rotationRadians }) {
2818
+ const patch = {};
2819
+ if (scale !== 1 && Number.isFinite(scale) && scale > 0) if (options.sizePixels !== void 0 || options.sizeMeters === void 0) patch.sizePixels = (options.sizePixels ?? DEFAULT_CROSS_TASK_OPTIONS.sizePixels) * scale;
2820
+ else patch.sizeMeters = options.sizeMeters * scale;
2821
+ if (rotationRadians !== 0 && Number.isFinite(rotationRadians)) patch.rotation = normalizeDegrees((options.rotation ?? 0) + rotationRadians * 180 / Math.PI);
2822
+ return Object.keys(patch).length ? patch : void 0;
2823
+ }
2824
+ /** The doctrinal letter each cross task centers. */
2825
+ const CROSS_TASK_GLYPHS = {
2826
+ destroy: "D",
2827
+ defeat: "D",
2828
+ neutralize: "N",
2829
+ suppress: "S"
2830
+ };
2831
+ /** Milsymbol 3.0.4, iconparts/tactical-points.js: TP.DESTROY / TP.NEUTRALIZE / TP.SUPPRESS.
2832
+ * Default to the 200 × 110 cross and 45-unit glyph; crossAngle adjusts its height. The center gap
2833
+ * follows resolved text metrics and padding rather than milsymbol’s fixed 30 units.
2834
+ * SVG's y=103 text anchor compensates for its font baseline; our labels are centered.
2835
+ */
2836
+ function createCrossTaskSymbol(coordinates, options, kind, context = {}) {
2837
+ const anchor = coordinates[0];
2838
+ if (!anchor) return {
2839
+ type: "FeatureCollection",
2840
+ features: []
2841
+ };
2842
+ const center = project(anchor[0], anchor[1]);
2843
+ const pixels = options.sizePixels ?? (options.sizeMeters === void 0 ? DEFAULT_CROSS_TASK_OPTIONS.sizePixels : void 0);
2844
+ const width = pixels !== void 0 && options.metersPerPixel !== void 0 && options.metersPerPixel > 0 ? pixels * options.metersPerPixel : options.sizeMeters ?? 1e3;
2845
+ if (!Number.isFinite(width) || width <= 0) return {
2846
+ type: "FeatureCollection",
2847
+ features: []
2848
+ };
2849
+ const scale = width / 200;
2850
+ const rotation = (options.rotation ?? 0) * Math.PI / 180;
2851
+ const cos = Math.cos(rotation);
2852
+ const sin = Math.sin(rotation);
2853
+ const local = (x, y) => unproject(center[0] + scale * (x * cos + y * sin), center[1] + scale * (-x * sin + y * cos));
2854
+ const glyph = CROSS_TASK_GLYPHS[kind];
2855
+ const labelOptions = {
2856
+ labelSize: 45 * scale,
2857
+ labelPadding: options.labelPadding
2858
+ };
2859
+ const labelContext = {
2860
+ ...context,
2861
+ labelSizeClampCssPixels: {
2862
+ min: 0,
2863
+ max: Number.MAX_VALUE
2864
+ }
2865
+ };
2866
+ const requestedAngle = options.crossAngle ?? DEFAULT_CROSS_TASK_OPTIONS.crossAngle;
2867
+ const angle = Number.isFinite(requestedAngle) ? Math.min(150, Math.max(10, requestedAngle)) : DEFAULT_CROSS_TASK_OPTIONS.crossAngle;
2868
+ const crossSlope = Math.tan(angle * Math.PI / 360);
2869
+ const diagonalScale = Math.hypot(1, crossSlope);
2870
+ const gap = Math.min(100, labelKnockoutHalfExtent(glyph, [1 / diagonalScale, crossSlope / diagonalScale], Math.PI, labelOptions, labelContext, DEFAULT_CROSS_TASK_OPTIONS.labelPadding) / (scale * diagonalScale));
2871
+ const label = createLabelFeature(center, glyph, Math.PI - rotation, labelOptions);
2872
+ if (pixels !== void 0) {
2873
+ delete label.properties.textSizeMeters;
2874
+ label.properties.textSizePixels = pixels * 45 / 200;
2875
+ } else label.properties.textMaxSizePixels = Number.MAX_SAFE_INTEGER;
2876
+ const resolution = context.constructionMetersPerCssPixel ?? options.metersPerPixel;
2877
+ const strokeWidth = (pixels ?? (resolution && resolution > 0 ? width / resolution : 80)) * (kind === "defeat" ? 8 : 4) / 200;
2878
+ if (gap >= 100) return {
2879
+ type: "FeatureCollection",
2880
+ features: [label]
2881
+ };
2882
+ if (kind === "defeat") {
2883
+ const shafts = [];
2884
+ const heads = [];
2885
+ const headLength = Math.min(32, (100 - gap) * diagonalScale * .65);
2886
+ const halfHeadWidth = headLength * .56;
2887
+ for (const xSign of [-1, 1]) for (const ySign of [-1, 1]) {
2888
+ const ux = xSign / diagonalScale;
2889
+ const uy = ySign * crossSlope / diagonalScale;
2890
+ const tipX = xSign * gap;
2891
+ const tipY = ySign * gap * crossSlope;
2892
+ const baseX = tipX + ux * headLength;
2893
+ const baseY = tipY + uy * headLength;
2894
+ shafts.push([local(xSign * 100, ySign * 100 * crossSlope), local(baseX, baseY)]);
2895
+ const tip = local(tipX, tipY);
2896
+ heads.push([[
2897
+ tip,
2898
+ local(baseX - uy * halfHeadWidth, baseY + ux * halfHeadWidth),
2899
+ local(baseX + uy * halfHeadWidth, baseY - ux * halfHeadWidth),
2900
+ tip
2901
+ ]]);
2902
+ }
2903
+ return {
2904
+ type: "FeatureCollection",
2905
+ features: [
2906
+ {
2907
+ type: "Feature",
2908
+ properties: {
2909
+ part: "defeat",
2910
+ style: { strokeWidth }
2911
+ },
2912
+ geometry: {
2913
+ type: "MultiLineString",
2914
+ coordinates: shafts
2915
+ }
2916
+ },
2917
+ {
2918
+ type: "Feature",
2919
+ properties: {
2920
+ part: "arrowheads",
2921
+ fill: true,
2922
+ style: {
2923
+ ...SOLID_ACCENT_FILL,
2924
+ strokeWidth: 0,
2925
+ strokeDash: []
2926
+ }
2927
+ },
2928
+ geometry: {
2929
+ type: "MultiPolygon",
2930
+ coordinates: heads
2931
+ }
2932
+ },
2933
+ label
2934
+ ]
2935
+ };
2936
+ }
2937
+ const dashLength = 24;
2938
+ const diagonalLines = (slope, dashed) => {
2939
+ const lines = [];
2940
+ for (const [start, end] of [[gap, 100], [-100, -gap]]) {
2941
+ if (!dashed) {
2942
+ lines.push([local(start, start * slope), local(end, end * slope)]);
2943
+ continue;
2944
+ }
2945
+ const length = (end - start) * diagonalScale;
2946
+ for (let distance = 0; distance < length; distance += 34) {
2947
+ const x1 = start + distance / diagonalScale;
2948
+ const x2 = start + Math.min(distance + dashLength, length) / diagonalScale;
2949
+ lines.push([local(x1, x1 * slope), local(x2, x2 * slope)]);
2950
+ }
2951
+ }
2952
+ return lines;
2953
+ };
2954
+ return {
2955
+ type: "FeatureCollection",
2956
+ features: [
2957
+ {
2958
+ type: "Feature",
2959
+ properties: {
2960
+ part: kind,
2961
+ style: {
2962
+ strokeWidth,
2963
+ ...kind === "suppress" ? { strokeDash: [] } : {}
2964
+ }
2965
+ },
2966
+ geometry: {
2967
+ type: "MultiLineString",
2968
+ coordinates: diagonalLines(-crossSlope, kind === "suppress")
2969
+ }
2970
+ },
2971
+ {
2972
+ type: "Feature",
2973
+ properties: {
2974
+ part: `${kind}-rising`,
2975
+ style: {
2976
+ strokeWidth,
2977
+ ...kind !== "destroy" ? { strokeDash: [] } : {}
2978
+ }
2979
+ },
2980
+ geometry: {
2981
+ type: "MultiLineString",
2982
+ coordinates: diagonalLines(crossSlope, kind !== "destroy")
2983
+ }
2984
+ },
2985
+ label
2986
+ ]
2987
+ };
2988
+ }
2989
+ //#endregion
2990
+ //#region src/generators/cm34-mission-tasks/defeat.ts
2991
+ const DEFAULT_DEFEAT_OPTIONS = {
2992
+ ...DEFAULT_CROSS_TASK_OPTIONS,
2993
+ crossAngle: 80
2994
+ };
2995
+ const DEFEAT_METADATA = {
2996
+ id: "defeat",
2997
+ sizePairs: [CROSS_TASK_SIZE_PAIR],
2998
+ name: "Defeat",
2999
+ description: "Renders an enemy force unable or unwilling to achieve its objectives.",
3000
+ entity: "Mission Tasks",
3001
+ entityType: "Defeat",
3002
+ value: "344300",
3003
+ minCoordinates: 1,
3004
+ maxCoordinates: 1,
3005
+ geometry: "point",
3006
+ geometryTypes: [
3007
+ "MultiLineString",
3008
+ "MultiPolygon",
3009
+ "Point"
3010
+ ],
3011
+ paints: {
3012
+ stroke: true,
3013
+ fill: "fixed",
3014
+ text: true
3015
+ },
3016
+ drawRule: "Point2",
3017
+ uniformSizing: true,
3018
+ labelGeometryUsesResolvedMetrics: true,
3019
+ params: CROSS_TASK_PARAMS
3020
+ };
3021
+ /** Generates the Defeat mission task (344300) around its sole center anchor. */
3022
+ function createDefeatSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3023
+ return createCrossTaskSymbol(coordinates, {
3024
+ ...options,
3025
+ crossAngle: Number.isFinite(options.crossAngle) ? options.crossAngle : DEFAULT_DEFEAT_OPTIONS.crossAngle
3026
+ }, "defeat", context);
3027
+ }
3028
+ const DEFEAT = defineControlMeasure({
3029
+ metadata: DEFEAT_METADATA,
3030
+ generator: createDefeatSymbol,
3031
+ defaultOptions: DEFAULT_DEFEAT_OPTIONS,
3032
+ rule: staticPointDrawRule,
3033
+ transformOptions: transformCrossTaskOptions,
3034
+ previewSample: {
3035
+ controlPoints: [[0, 0]],
3036
+ options: { sizeMeters: 1e3 }
3037
+ }
3038
+ });
3039
+ //#endregion
3040
+ //#region src/generators/cm34-mission-tasks/suppress.ts
3041
+ const DEFAULT_SUPPRESS_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3042
+ const SUPPRESS_METADATA = {
3043
+ id: "suppress",
3044
+ sizePairs: [CROSS_TASK_SIZE_PAIR],
3045
+ name: "Suppress",
3046
+ description: "Temporarily degrades an enemy force or weapon system below the level needed to accomplish its mission.",
3047
+ entity: "Mission Tasks",
3048
+ entityType: "Suppress",
3049
+ value: "342800",
3050
+ minCoordinates: 1,
3051
+ maxCoordinates: 1,
3052
+ geometry: "point",
3053
+ geometryTypes: ["MultiLineString", "Point"],
3054
+ paints: {
3055
+ stroke: true,
3056
+ fill: "none",
3057
+ text: true
3058
+ },
3059
+ drawRule: "Point2",
3060
+ uniformSizing: true,
3061
+ labelGeometryUsesResolvedMetrics: true,
3062
+ params: CROSS_TASK_PARAMS
3063
+ };
3064
+ /** Generates the Suppress mission task (342800) around its sole center anchor. */
3065
+ function createSuppressSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3066
+ return createCrossTaskSymbol(coordinates, options, "suppress", context);
3067
+ }
3068
+ const SUPPRESS = defineControlMeasure({
3069
+ metadata: SUPPRESS_METADATA,
3070
+ generator: createSuppressSymbol,
3071
+ defaultOptions: DEFAULT_SUPPRESS_OPTIONS,
3072
+ rule: staticPointDrawRule,
3073
+ transformOptions: transformCrossTaskOptions,
3074
+ previewSample: {
3075
+ controlPoints: [[0, 0]],
3076
+ options: { sizeMeters: 1e3 }
3077
+ }
3078
+ });
3079
+ //#endregion
3080
+ //#region src/generators/cm34-mission-tasks/neutralize.ts
3081
+ const DEFAULT_NEUTRALIZE_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3082
+ const NEUTRALIZE_METADATA = {
3083
+ id: "neutralize",
3084
+ sizePairs: [CROSS_TASK_SIZE_PAIR],
3085
+ name: "Neutralize",
3086
+ description: "Renders enemy personnel or materiel incapable of interfering with an operation.",
3087
+ entity: "Mission Tasks",
3088
+ entityType: "Neutralize",
3089
+ value: "341600",
3090
+ minCoordinates: 1,
3091
+ maxCoordinates: 1,
3092
+ geometry: "point",
3093
+ geometryTypes: ["MultiLineString", "Point"],
3094
+ paints: {
3095
+ stroke: true,
3096
+ fill: "none",
3097
+ text: true
3098
+ },
3099
+ drawRule: "Point2",
3100
+ uniformSizing: true,
3101
+ labelGeometryUsesResolvedMetrics: true,
3102
+ params: CROSS_TASK_PARAMS
3103
+ };
3104
+ /** Generates the Neutralize mission task (341600) around its sole center anchor. */
3105
+ function createNeutralizeSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3106
+ return createCrossTaskSymbol(coordinates, options, "neutralize", context);
3107
+ }
3108
+ const NEUTRALIZE = defineControlMeasure({
3109
+ metadata: NEUTRALIZE_METADATA,
3110
+ generator: createNeutralizeSymbol,
3111
+ defaultOptions: DEFAULT_NEUTRALIZE_OPTIONS,
3112
+ rule: staticPointDrawRule,
3113
+ transformOptions: transformCrossTaskOptions,
3114
+ previewSample: {
3115
+ controlPoints: [[0, 0]],
3116
+ options: { sizeMeters: 1e3 }
3117
+ }
3118
+ });
3119
+ //#endregion
3120
+ //#region src/generators/cm34-mission-tasks/destroy.ts
3121
+ const DEFAULT_DESTROY_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3122
+ const DESTROY_METADATA = {
3123
+ id: "destroy",
3124
+ sizePairs: [CROSS_TASK_SIZE_PAIR],
3125
+ name: "Destroy",
3126
+ description: "Renders an enemy force or capability physically combat-ineffective until reconstituted.",
3127
+ entity: "Mission Tasks",
3128
+ entityType: "Destroy",
3129
+ value: "340900",
3130
+ minCoordinates: 1,
3131
+ maxCoordinates: 1,
3132
+ geometry: "point",
3133
+ geometryTypes: ["MultiLineString", "Point"],
3134
+ paints: {
3135
+ stroke: true,
3136
+ fill: "none",
3137
+ text: true
3138
+ },
3139
+ drawRule: "Point2",
3140
+ uniformSizing: true,
3141
+ labelGeometryUsesResolvedMetrics: true,
3142
+ params: CROSS_TASK_PARAMS
3143
+ };
3144
+ /** Generates the Destroy mission task (340900) around its sole center anchor. */
3145
+ function createDestroySymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3146
+ return createCrossTaskSymbol(coordinates, options, "destroy", context);
3147
+ }
3148
+ const DESTROY = defineControlMeasure({
3149
+ metadata: DESTROY_METADATA,
3150
+ generator: createDestroySymbol,
3151
+ defaultOptions: DEFAULT_DESTROY_OPTIONS,
3152
+ rule: staticPointDrawRule,
3153
+ transformOptions: transformCrossTaskOptions,
3154
+ previewSample: {
3155
+ controlPoints: [[0, 0]],
3156
+ options: { sizeMeters: 1e3 }
3157
+ }
3158
+ });
3159
+ //#endregion
3160
+ //#region src/internal/echelon.ts
3161
+ /**
3162
+ * APP-6 echelon (Field B) glyph vocabulary, shared by the control measures that
3163
+ * stamp a unit-size amplifier onto their geometry (Boundary, Battle Position).
3164
+ *
3165
+ * A compact port of the APP-6 echelon designs, modeled on milsymbol but
3166
+ * expressed as parametric geometry so it bakes into GeoJSON and strokes
3167
+ * monocolor (ADR-0023). Every glyph is built from three primitives — vertical
3168
+ * bars, crosses, and dots — plus the team/crew ring and command `++`.
3169
+ */
3170
+ /** Shared echelon dimension for every measure that stamps a unit-size amplifier. */
3171
+ const ECHELON_SIZE_PAIR = {
3172
+ id: "echelon",
3173
+ label: "Echelon size",
3174
+ pixels: "echelonSizePixels",
3175
+ meters: "echelonSize",
3176
+ defaultUnit: "m",
3177
+ rendering: "geometry"
3178
+ };
3179
+ const ECHELONS = {
3180
+ team: {
3181
+ kind: "ring",
3182
+ count: 1,
3183
+ label: "Team/Crew"
3184
+ },
3185
+ crew: {
3186
+ kind: "ring",
3187
+ count: 1
3188
+ },
3189
+ squad: {
3190
+ kind: "dots",
3191
+ count: 1,
3192
+ label: "Squad"
3193
+ },
3194
+ section: {
3195
+ kind: "dots",
3196
+ count: 2,
3197
+ label: "Section"
3198
+ },
3199
+ platoon: {
3200
+ kind: "dots",
3201
+ count: 3,
3202
+ label: "Platoon"
3203
+ },
3204
+ company: {
3205
+ kind: "bars",
3206
+ count: 1,
3207
+ label: "Company"
3208
+ },
3209
+ battalion: {
3210
+ kind: "bars",
3211
+ count: 2,
3212
+ label: "Battalion"
3213
+ },
3214
+ regiment: {
3215
+ kind: "bars",
3216
+ count: 3,
3217
+ label: "Regiment"
3218
+ },
3219
+ brigade: {
3220
+ kind: "crosses",
3221
+ count: 1,
3222
+ label: "Brigade"
3223
+ },
3224
+ division: {
3225
+ kind: "crosses",
3226
+ count: 2,
3227
+ label: "Division"
3228
+ },
3229
+ corps: {
3230
+ kind: "crosses",
3231
+ count: 3,
3232
+ label: "Corps"
3233
+ },
3234
+ army: {
3235
+ kind: "crosses",
3236
+ count: 4,
3237
+ label: "Army"
3238
+ },
3239
+ "army-group": {
3240
+ kind: "crosses",
3241
+ count: 5,
3242
+ label: "Army Group"
3243
+ },
3244
+ region: {
3245
+ kind: "crosses",
3246
+ count: 6,
3247
+ label: "Region"
3248
+ },
3249
+ command: {
3250
+ kind: "command",
3251
+ count: 2,
3252
+ label: "Command"
3253
+ }
3254
+ };
3255
+ /** Symbol-form aliases (`"XX"`, `"II"`, `"•••"`, …) → canonical echelon key. */
3256
+ const ECHELON_ALIASES = {
3257
+ ø: "team",
3258
+ "•": "squad",
3259
+ "••": "section",
3260
+ "•••": "platoon",
3261
+ i: "company",
3262
+ ii: "battalion",
3263
+ iii: "regiment",
3264
+ x: "brigade",
3265
+ xx: "division",
3266
+ xxx: "corps",
3267
+ xxxx: "army",
3268
+ xxxxx: "army-group",
3269
+ xxxxxx: "region",
3270
+ "++": "command"
3271
+ };
3272
+ /** Enum options for the params panel, derived from {@link ECHELONS}. */
3273
+ const ECHELON_PARAM_OPTIONS = [{
3274
+ label: "None",
3275
+ value: "none"
3276
+ }, ...Object.entries(ECHELONS).filter(([, spec]) => spec.label !== void 0).map(([value, spec]) => ({
3277
+ label: spec.label,
3278
+ value
3279
+ }))];
3280
+ /**
3281
+ * Effective echelon glyph height in meters. Screen-anchored when a pixel size
3282
+ * is supplied with a live resolution; ground-anchored (meters) otherwise.
3283
+ * Clamped strictly positive.
3284
+ */
3285
+ function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
3286
+ let h = echelonSize;
3287
+ if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
3288
+ return Math.max(EPSILON, h);
3289
+ }
3290
+ function resolveEchelon(echelon) {
3291
+ const key = echelon.trim().toLowerCase();
3292
+ if (key.length === 0) return void 0;
3293
+ const alias = ECHELON_ALIASES[key];
3294
+ return ECHELONS[key] ?? (alias ? ECHELONS[alias] : void 0);
3295
+ }
3296
+ /**
3297
+ * Builds the echelon glyph centered at `center`, with its baseline along
3298
+ * `along` and `perp` (both unit vectors), `h` meters tall. Returns the glyph's
3299
+ * `strokes` (open polylines), `fills` (closed rings drawn as solid shapes —
3300
+ * the squad/section/platoon dots), and its intrinsic width along the line; the
3301
+ * caller adds the configurable side padding to derive the line gap.
3302
+ */
3303
+ function buildEchelonGlyph(center, along, perp, h, spec) {
3304
+ const P = (a, p) => {
3305
+ const q = vecAdd(vecAdd(center, vecScale(along, a)), vecScale(perp, p));
3306
+ return unproject(q[0], q[1]);
3307
+ };
3308
+ const ring = (a, r) => {
3309
+ const pts = [];
3310
+ const steps = 16;
3311
+ for (let i = 0; i <= steps; i++) {
3312
+ const t = 2 * Math.PI * i / steps;
3313
+ pts.push(P(a + r * Math.cos(t), r * Math.sin(t)));
3314
+ }
3315
+ return pts;
3316
+ };
3317
+ const strokes = [];
3318
+ const fills = [];
3319
+ const half = h / 2;
3320
+ switch (spec.kind) {
3321
+ case "bars": {
3322
+ const spacing = h * .8;
3323
+ const total = (spec.count - 1) * spacing;
3324
+ for (let j = 0; j < spec.count; j++) {
3325
+ const a = -total / 2 + j * spacing;
3326
+ strokes.push([P(a, -half), P(a, half)]);
3327
+ }
3328
+ return {
3329
+ strokes,
3330
+ fills,
3331
+ width: total
3332
+ };
3333
+ }
3334
+ case "crosses": {
3335
+ const w = h;
3336
+ const spacing = h * 1.4;
3337
+ const total = (spec.count - 1) * spacing;
3338
+ for (let j = 0; j < spec.count; j++) {
3339
+ const a0 = -total / 2 + j * spacing;
3340
+ strokes.push([P(a0 - w / 2, -half), P(a0 + w / 2, half)]);
3341
+ strokes.push([P(a0 - w / 2, half), P(a0 + w / 2, -half)]);
3342
+ }
3343
+ return {
3344
+ strokes,
3345
+ fills,
3346
+ width: total + w
3347
+ };
3348
+ }
3349
+ case "dots": {
3350
+ const r = h * .3;
3351
+ const spacing = h * 1.2;
3352
+ const total = (spec.count - 1) * spacing;
3353
+ for (let j = 0; j < spec.count; j++) fills.push(ring(-total / 2 + j * spacing, r));
3354
+ return {
3355
+ strokes,
3356
+ fills,
3357
+ width: total + 2 * r
3358
+ };
3359
+ }
3360
+ case "ring": {
3361
+ const r = h * .6;
3362
+ strokes.push(ring(0, r));
3363
+ strokes.push([P(-h * .8, -h * .4), P(h * .8, h * .4)]);
3364
+ return {
3365
+ strokes,
3366
+ fills,
3367
+ width: h * 1.6
3368
+ };
3369
+ }
3370
+ case "command": {
3371
+ const arm = h * .5;
3372
+ const spacing = h * 1.4;
3373
+ const total = (spec.count - 1) * spacing;
3374
+ for (let j = 0; j < spec.count; j++) {
3375
+ const a0 = -total / 2 + j * spacing;
3376
+ strokes.push([P(a0 - arm, 0), P(a0 + arm, 0)]);
3377
+ strokes.push([P(a0, -half), P(a0, half)]);
3378
+ }
3379
+ return {
3380
+ strokes,
3381
+ fills,
3382
+ width: total + 2 * arm
3383
+ };
3384
+ }
3385
+ }
3386
+ }
3387
+ //#endregion
3388
+ //#region src/generators/cm15-maneuver-areas/params.ts
3389
+ const AREA_SMOOTH_PARAMS = [{
3390
+ key: "smooth",
3391
+ label: "Smooth",
3392
+ description: "Round the area's corners by curving the boundary through the control points.",
3393
+ type: "boolean"
3394
+ }, {
3395
+ key: "smoothResolution",
3396
+ presentationTier: "advanced",
3397
+ label: "Smooth resolution",
3398
+ description: "Number of samples per segment when smooth mode is enabled.",
3399
+ type: "number",
3400
+ min: 2,
3401
+ max: 64,
3402
+ step: 1,
3403
+ visibleWhen: (opts) => Boolean(opts.smooth)
3404
+ }];
3405
+ const ATTACK_SHAFT_PARAMS = [
3406
+ {
3407
+ key: "shaftWidthRatio",
3408
+ presentationTier: "advanced",
3409
+ label: "Shaft width",
3410
+ description: "Arrow shaft width as a ratio of the arrowhead width",
3411
+ type: "number",
3412
+ min: .1,
3413
+ max: 1,
3414
+ step: .05
3415
+ },
3416
+ {
3417
+ key: "rearWidthRatio",
3418
+ presentationTier: "advanced",
3419
+ label: "Rear width",
3420
+ description: "Width at the rear of the shaft as a ratio of the arrowhead width.",
3421
+ type: "number",
3422
+ min: .1,
3423
+ max: 2,
3424
+ step: .05
3425
+ },
3426
+ {
3427
+ key: "smooth",
3428
+ label: "Smooth",
3429
+ description: "Round the corners where the shaft changes direction.",
3430
+ type: "boolean"
3431
+ },
3432
+ {
3433
+ key: "smoothResolution",
3434
+ presentationTier: "advanced",
3435
+ label: "Smooth resolution",
3436
+ description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
3437
+ type: "number",
3438
+ min: 1,
3439
+ max: 20,
3440
+ step: 1,
3441
+ visibleWhen: (opts) => opts.smooth === true
3442
+ }
3443
+ ];
3444
+ const FIRE_ARROW_PARAMS = [
3445
+ {
3446
+ key: "arrowheadWidthRatio",
3447
+ presentationTier: "advanced",
3448
+ label: "Arrowhead width",
3449
+ description: "Arrowhead width as a ratio of the shaft length",
3450
+ type: "number",
3451
+ min: .05,
3452
+ max: 1,
3453
+ step: .01
3454
+ },
3455
+ {
3456
+ key: "rearLineLengthRatio",
3457
+ presentationTier: "advanced",
3458
+ label: "Rear line length",
3459
+ description: "Length of the rear flare lines as a ratio of the head",
3460
+ type: "number",
3461
+ min: .05,
3462
+ max: 1,
3463
+ step: .01
3464
+ },
3465
+ {
3466
+ key: "rearLineAngle",
3467
+ presentationTier: "advanced",
3468
+ label: "Rear line angle",
3469
+ description: "Angle of the rear flare lines, in degrees",
3470
+ type: "number",
3471
+ min: 0,
3472
+ max: 90,
3473
+ step: 1
3474
+ }
3475
+ ];
3476
+ const FORTIFIED_AREA_SIZE_PARAMS = [{
3477
+ key: "toothSizePixels",
3478
+ label: "Tooth size",
3479
+ description: "Size of the fortified-area teeth, in pixels",
3480
+ type: "number",
3481
+ min: 5,
3482
+ max: 50,
3483
+ step: 1,
3484
+ unit: "px"
3485
+ }, {
3486
+ key: "toothSize",
3487
+ label: "Tooth size",
3488
+ description: "Size of the fortified-area teeth, in meters",
3489
+ type: "number",
3490
+ min: 1,
3491
+ max: 500,
3492
+ step: 1,
3493
+ unit: "m"
3494
+ }];
3495
+ const ENCIRCLEMENT_PARAMS = [
3496
+ {
3497
+ key: "smooth",
3498
+ label: "Smooth",
3499
+ description: "Round the boundary by curving it through the control points.",
3500
+ type: "boolean"
3501
+ },
3502
+ {
3503
+ key: "smoothResolution",
3504
+ presentationTier: "advanced",
3505
+ label: "Smooth resolution",
3506
+ description: "Number of samples per segment when smooth mode is enabled.",
3507
+ type: "number",
3508
+ min: 2,
3509
+ max: 64,
3510
+ step: 1,
3511
+ visibleWhen: (opts) => Boolean(opts.smooth)
3512
+ },
3513
+ {
3514
+ key: "barbSpacingRatio",
3515
+ presentationTier: "advanced",
3516
+ label: "Spacing",
3517
+ description: "Spacing between barbs as a ratio of the symbol radius (smaller is denser).",
3518
+ type: "number",
3519
+ min: .15,
3520
+ max: 1.5,
3521
+ step: .05
3522
+ },
3523
+ {
3524
+ key: "barbLengthRatio",
3525
+ presentationTier: "advanced",
3526
+ label: "Barb length",
3527
+ description: "Barb height as a ratio of the symbol radius (amplitude).",
3528
+ type: "number",
3529
+ min: .02,
3530
+ max: .5,
3531
+ step: .01
3532
+ }
3533
+ ];
3534
+ const FORTIFIED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, ...FORTIFIED_AREA_SIZE_PARAMS];
3535
+ const AREA_LABEL_PADDING_PARAM = {
3536
+ key: "labelPadding",
3537
+ presentationTier: "advanced",
3538
+ label: "Label padding",
3539
+ description: "Extra spacing between or around the area's labels, as a ratio of label height",
3540
+ type: "number",
3541
+ min: 0,
3542
+ max: 2,
3543
+ step: .05
3544
+ };
3545
+ const MULTILINE_AREA_LABEL_PARAM = {
3546
+ key: "multilineLabel",
3547
+ label: "Multiline label",
3548
+ description: "Place the doctrinal label above Field T on separate centered lines",
3549
+ type: "boolean"
3550
+ };
3551
+ const LABELED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, AREA_LABEL_PADDING_PARAM];
3552
+ const PREFIXED_LABELED_AREA_PARAMS = [
3553
+ ...AREA_SMOOTH_PARAMS,
3554
+ MULTILINE_AREA_LABEL_PARAM,
3555
+ AREA_LABEL_PADDING_PARAM
3556
+ ];
3557
+ const ECHELON_GLYPH_PARAMS = [
3558
+ {
3559
+ key: "echelon",
3560
+ label: "Echelon",
3561
+ description: "Field B unit-size amplifier, drawn on the boundary.",
3562
+ type: "enum",
3563
+ presentationTier: "primary",
3564
+ semanticRole: "doctrinal",
3565
+ options: ECHELON_PARAM_OPTIONS
3566
+ },
3567
+ {
3568
+ key: "echelonSizePixels",
3569
+ label: "Echelon size",
3570
+ description: "Echelon glyph height in screen pixels.",
3571
+ type: "number",
3572
+ min: 8,
3573
+ max: 48,
3574
+ step: 1,
3575
+ unit: "px"
3576
+ },
3577
+ {
3578
+ key: "echelonSize",
3579
+ label: "Echelon size",
3580
+ description: "Echelon glyph height in meters.",
3581
+ type: "number",
3582
+ min: 50,
3583
+ max: 2e4,
3584
+ step: 50,
3585
+ unit: "m"
3586
+ },
3587
+ {
3588
+ key: "echelonPadding",
3589
+ presentationTier: "advanced",
3590
+ label: "Echelon padding",
3591
+ description: "Gap on each side of the echelon, as a ratio of its height.",
3592
+ type: "number",
3593
+ min: 0,
3594
+ max: 2,
3595
+ step: .05
3596
+ },
3597
+ {
3598
+ key: "echelonPosition",
3599
+ presentationTier: "advanced",
3600
+ label: "Echelon position",
3601
+ description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
3602
+ type: "number",
3603
+ min: -.5,
3604
+ max: .5,
3605
+ step: .01
3606
+ }
3607
+ ];
3608
+ const ECHELON_AREA_PARAMS = [
3609
+ ...AREA_SMOOTH_PARAMS,
3610
+ ...ECHELON_GLYPH_PARAMS,
3611
+ AREA_LABEL_PADDING_PARAM
3612
+ ];
3613
+ const PREFIXED_ECHELON_AREA_PARAMS = [
3614
+ ...AREA_SMOOTH_PARAMS,
3615
+ MULTILINE_AREA_LABEL_PARAM,
3616
+ ...ECHELON_GLYPH_PARAMS,
3617
+ AREA_LABEL_PADDING_PARAM
3618
+ ];
3619
+ const AREA_DESIGNATION_AMPLIFIER = {
3620
+ key: "T",
3621
+ label: "Unique designation",
3622
+ description: "Field T — the unit's designation, centered in the area.",
3623
+ maxLength: 21
3624
+ };
3625
+ const AREA_HOSTILE_AMPLIFIER = {
3626
+ key: "N",
3627
+ label: "Hostile (ENY) marker",
3628
+ description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
3213
3629
  placeholder: "ENY",
3214
3630
  maxLength: 3
3215
3631
  };
3216
- /** Label-padding control shared by label-bearing graphics. */
3217
- const LABEL_PADDING_PARAM = {
3218
- key: "labelPadding",
3219
- presentationTier: "advanced",
3220
- label: "Label padding",
3221
- description: "Extra clearance around labels, as a ratio of the label height",
3222
- type: "number",
3223
- min: 0,
3224
- max: 2,
3225
- step: .05
3632
+ const AREA_TEXT_AMPLIFIERS = [
3633
+ {
3634
+ key: "T",
3635
+ label: "Unique designation",
3636
+ description: "Field T — the area's unique designation.",
3637
+ maxLength: 21
3638
+ },
3639
+ {
3640
+ key: "H",
3641
+ label: "Additional information",
3642
+ description: "Field H — additional free-text information, shown below the designation.",
3643
+ maxLength: 20
3644
+ },
3645
+ {
3646
+ key: "W",
3647
+ label: "Effective from (DTG)",
3648
+ description: "Field W — date-time group the area becomes effective.",
3649
+ placeholder: "051030Z",
3650
+ maxLength: 16
3651
+ },
3652
+ {
3653
+ key: "W1",
3654
+ label: "Effective to (DTG)",
3655
+ description: "Field W1 — date-time group the area ceases to be effective.",
3656
+ maxLength: 16
3657
+ },
3658
+ AREA_HOSTILE_AMPLIFIER
3659
+ ];
3660
+ const AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS = [AREA_DESIGNATION_AMPLIFIER, AREA_HOSTILE_AMPLIFIER];
3661
+ /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
3662
+ * for measures whose template omits that row but keeps the W/W1 window and
3663
+ * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
3664
+ * Area). */
3665
+ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "H");
3666
+ /** {@link AREA_TEXT_AMPLIFIERS} reduced to just the designation (Field T) and
3667
+ * hostile marker (Field N), for measures whose template carries no
3668
+ * additional-information or date-time-group rows (Area, Drop Zone,
3669
+ * Extraction Zone). */
3670
+ const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
3671
+ //#endregion
3672
+ //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
3673
+ const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
3674
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
3675
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
3676
+ smooth: false,
3677
+ smoothResolution: 5
3678
+ };
3679
+ const AIRBORNE_ATTACK_METADATA = {
3680
+ id: "airborne-attack",
3681
+ name: "Airborne Attack",
3682
+ description: "Airborne or aviation axis of advance.",
3683
+ entity: "Maneuver Areas",
3684
+ entityType: "Axis of Advance",
3685
+ entitySubtype: "Airborne Attack",
3686
+ value: "151401",
3687
+ minCoordinates: 3,
3688
+ geometry: "line",
3689
+ geometryTypes: ["LineString"],
3690
+ paints: {
3691
+ stroke: true,
3692
+ fill: "none",
3693
+ text: false
3694
+ },
3695
+ drawRule: "Axis1",
3696
+ params: ATTACK_SHAFT_PARAMS
3226
3697
  };
3227
3698
  /**
3228
- * Resolves {@link LabelSizeOptions} into the single text-size property the
3229
- * label feature carries (ADR-0028): `textSizePixels` when screen-anchored
3230
- * (`labelSizePixels` present — pixels win over a stray `labelSize`),
3231
- * `textSizeMeters` when ground-anchored (`labelSize` alone), or neither, in
3232
- * which case adapters render their engine default, screen-fixed.
3233
- */
3234
- function labelSizeProps(options) {
3235
- const { labelSize, labelSizePixels } = options;
3236
- if (labelSizePixels !== void 0) return { textSizePixels: labelSizePixels };
3237
- if (labelSize !== void 0) return { textSizeMeters: labelSize };
3238
- return {};
3239
- }
3240
- /** Pushes a `part: "label"` Point feature at projected `point`. */
3241
- function pushLabel(out, point, text, rotation, sizeProps, textAnchor, amplifierField, labelPlacementKey) {
3242
- out.push({
3243
- type: "Feature",
3244
- properties: {
3245
- part: "label",
3246
- text,
3247
- rotation,
3248
- ...textAnchor ? { textAnchor } : {},
3249
- ...amplifierField ? { amplifierField } : {},
3250
- ...labelPlacementKey ? { labelPlacementKey } : {},
3251
- ...sizeProps
3252
- },
3253
- geometry: {
3254
- type: "Point",
3255
- coordinates: unproject(point[0], point[1])
3256
- }
3257
- });
3258
- }
3259
- /** Ratio of resolved label height (`labelSize`, or `labelSizePixels × metersPerPixel`) used as end-label clearance. */
3260
- const END_LABEL_CLEARANCE_RATIO = .3;
3261
- /** Ratio of resolved label height used to inset "above" end labels from the tip. */
3262
- const ABOVE_LABEL_INSET_RATIO = .3;
3263
- /** Ratio of resolved label height used as the "above" end labels' perpendicular offset. */
3264
- const ABOVE_LABEL_OFFSET_RATIO = .6;
3265
- function textHeight(options) {
3266
- const { labelSize, labelSizePixels, metersPerPixel } = options;
3267
- if (labelSizePixels !== void 0) return metersPerPixel !== void 0 && metersPerPixel > 0 ? labelSizePixels * metersPerPixel : void 0;
3268
- return labelSize;
3269
- }
3270
- /**
3271
- * Label offset in meters: `ratio` × the label's resolved height (`labelSize`
3272
- * meters, or `labelSizePixels × metersPerPixel` when screen-anchored), or
3273
- * `fallbackMeters` when no size resolves (e.g. no map resolution yet). The fallback is small
3274
- * relative to typical control measure spans — the same order of magnitude as
3275
- * Boundary's own smallest label offsets — so a fixed label still reads as "just
3276
- * clear of the line" rather than crowding it or floating away. Callers supply
3277
- * their own ratio (end-label clearance, "above" inset/offset, center-group
3278
- * offset) but share this single resolution+fallback rule.
3279
- */
3280
- function resolveLabelOffsetMeters(options, ratio, fallbackMeters = 50) {
3281
- const h = textHeight(options);
3282
- return h !== void 0 ? ratio * h : fallbackMeters;
3283
- }
3284
- /**
3285
- * Places one label just beyond each end of `verts` (projected meters), along
3286
- * the outward direction of that end's segment, reading along the line
3287
- * ({@link labelRotationAlong} is sign-independent, so the outward-pointing
3288
- * `along` reads correctly). `textForEnd` supplies each end's text — a shared
3289
- * string, or a function keyed by which end; an empty string skips that end.
3699
+ * Generates a GeoJSON FeatureCollection for an Airborne Attack tactical symbol.
3290
3700
  *
3291
- * `textAnchor` is chosen so the text grows AWAY from the line: adapters flip
3292
- * a label's rendered baseline (via {@link keepTextLeftToRight}) whenever its
3293
- * direction points leftward, and that flip happens exactly when `along[0] <
3294
- * 0` (same quadrant test `labelRotationAlong` relies on) — mirroring
3295
- * Boundary's `vecDot` sign trick, "start" anchors the text at the point when
3296
- * there is no flip, "end" when there is, so the anchored edge always sits at
3297
- * the point and the text body always extends outward.
3298
- */
3299
- function pushEndLabels(out, verts, textForEnd, options = {}) {
3300
- const frames = endFrames(verts);
3301
- const sizeProps = labelSizeProps(options);
3302
- const clearance = resolveLabelOffsetMeters(options, END_LABEL_CLEARANCE_RATIO + Math.max(0, options.labelPadding ?? 0));
3303
- for (const end of ["start", "end"]) {
3304
- const frame = frames[end];
3305
- if (!frame) continue;
3306
- const text = typeof textForEnd === "string" ? textForEnd : textForEnd(end);
3307
- if (text.length === 0) continue;
3308
- pushLabel(out, vecAdd(frame.point, vecScale(frame.along, clearance)), text, labelRotationAlong(frame.along), sizeProps, frame.along[0] >= 0 ? "start" : "end");
3309
- }
3310
- }
3311
- /**
3312
- * Approximate sans-serif character width/height ratio, used to estimate a
3313
- * label's rendered text width from its character count when no live text
3314
- * measurement is available at generator time.
3315
- */
3316
- const LABEL_CHAR_ASPECT_RATIO = .6;
3317
- /**
3318
- * Half a single glyph's width, as a ratio of its height — the base boundary
3319
- * gap a measure reserves for one intrinsic capital letter (Retain's R,
3320
- * Contain's C). Wider than half `LABEL_CHAR_ASPECT_RATIO` because these
3321
- * glyphs are stamped alone rather than averaged over a word.
3701
+ * The symbol is similar to Supporting Attack but features a crossover point
3702
+ * between the shaft and the head (Points 1 and 2/Neck).
3703
+ *
3704
+ * @param coordinates - An array of GeoJSON Positions.
3705
+ * @param options - Configuration options.
3706
+ * @returns A GeoJSON FeatureCollection<LineString>.
3322
3707
  */
3323
- const LABEL_HALF_GLYPH_RATIO = .45;
3324
- /** Estimated rendered width of `text` given its resolved label height in meters. */
3325
- function estimatedTextWidth(text, textHeightMeters) {
3326
- return text.length * LABEL_CHAR_ASPECT_RATIO * textHeightMeters;
3327
- }
3328
- /** Resolve the label's final display size, then measure and convert it to construction metres. */
3329
- function resolveTextMetrics(text, options, context, style = "regular", fallbackHeightMeters = 50) {
3330
- const legacyHeight = resolveLabelOffsetMeters(options, 1, fallbackHeightMeters);
3331
- const constructionScale = context?.constructionMetersPerCssPixel;
3332
- if (!(constructionScale !== void 0 && constructionScale > 0)) return {
3333
- width: estimatedTextWidth(text, legacyHeight),
3334
- height: legacyHeight
3335
- };
3336
- let sizeCssPixels;
3337
- if (options.labelSizePixels !== void 0) sizeCssPixels = options.labelSizePixels;
3338
- else if (options.labelSize !== void 0) {
3339
- const requested = options.labelSize / constructionScale;
3340
- const requestedBand = context?.labelSizeClampCssPixels;
3341
- const band = requestedBand && Number.isFinite(requestedBand.min) && requestedBand.min >= 0 && Number.isFinite(requestedBand.max) && requestedBand.max >= 0 ? requestedBand : DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS;
3342
- const min = Math.min(band.min, band.max);
3343
- const max = Math.max(band.min, band.max);
3344
- sizeCssPixels = Math.min(max, Math.max(min, requested));
3345
- } else sizeCssPixels = 14;
3346
- const fallbackHeight = sizeCssPixels * constructionScale;
3347
- const measured = context?.measureText?.({
3348
- text,
3349
- style,
3350
- sizeCssPixels
3351
- });
3352
- if (!measured || !Number.isFinite(measured.widthCssPixels) || measured.widthCssPixels < 0 || !Number.isFinite(measured.heightCssPixels) || measured.heightCssPixels < 0) return {
3353
- width: estimatedTextWidth(text, fallbackHeight),
3354
- height: fallbackHeight
3708
+ function createAirborneAttack(coordinates, options = {}) {
3709
+ const { geometry } = processAttackGeometry(coordinates, options);
3710
+ if (!geometry) return {
3711
+ type: "FeatureCollection",
3712
+ features: []
3355
3713
  };
3714
+ const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
3715
+ const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
3356
3716
  return {
3357
- width: measured.widthCssPixels * constructionScale,
3358
- height: measured.heightCssPixels * constructionScale
3717
+ type: "FeatureCollection",
3718
+ features: [{
3719
+ type: "Feature",
3720
+ properties: {},
3721
+ geometry: {
3722
+ type: "LineString",
3723
+ coordinates: [
3724
+ ...shaftLeftToNeck,
3725
+ geometry.headRing[3],
3726
+ geometry.headRing[2],
3727
+ geometry.headRing[1],
3728
+ geometry.headRing[0],
3729
+ geometry.headRing[5],
3730
+ ...[...shaftRightToNeck].reverse()
3731
+ ].map((p) => unproject(p[0], p[1]))
3732
+ }
3733
+ }]
3359
3734
  };
3360
3735
  }
3736
+ const AIRBORNE_ATTACK = defineControlMeasure({
3737
+ metadata: AIRBORNE_ATTACK_METADATA,
3738
+ generator: createAirborneAttack,
3739
+ defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
3740
+ rule: axis1DrawRule,
3741
+ optionHandles: variableWidthAttackOptionHandles
3742
+ });
3361
3743
  /**
3362
- * Half-length of the knockout required for a label along `direction`.
3744
+ * Reacts to an **input-contract** violation according to `mode`: `throw`
3745
+ * raises, `warn` logs, `silent` (the default) does neither. Always returns
3746
+ * `false` so a caller can `return reportValidationIssue(...)` from a guard.
3363
3747
  *
3364
- * The text box itself is always cleared; `labelPadding` adds symmetric clearance expressed in
3365
- * resolved label heights. Keeping this calculation here gives every generator the same public
3366
- * label-clearance interface while hiding font measurement and rotated-box projection details.
3748
+ * Governs the *input contract* only (control-point count and finiteness),
3749
+ * checked once at the render seam — not geometric degeneracy, which always
3750
+ * yields an empty render silently. See ADR-0014 and the `CONTEXT.md` terms.
3367
3751
  */
3368
- function labelKnockoutHalfExtent(text, direction, rotation, options, context, defaultPadding = 0) {
3369
- const metrics = resolveTextMetrics(text, options, context);
3370
- const padding = Math.max(0, options.labelPadding ?? defaultPadding) * metrics.height;
3371
- return labelHalfExtent(direction, metrics.width, metrics.height, rotation) + padding;
3752
+ function reportValidationIssue(mode, message) {
3753
+ const resolved = mode ?? "silent";
3754
+ if (resolved === "throw") throw new Error(message);
3755
+ if (resolved === "warn") console.warn(message);
3756
+ return false;
3372
3757
  }
3373
3758
  /**
3374
- * {@link labelKnockoutHalfExtent} expressed as a fraction of an arc's angular
3375
- * sweep, for generators that carve their knockout in arc-progress space rather
3376
- * than in meters. Clamped to `0.5` so the gap can never exceed the arc.
3759
+ * Narrows a control-point array to the fixed `[PT1, PT2, PT3]` tuple the ambush
3760
+ * and search-area generators require. The render seam has already validated
3761
+ * that at least three valid points are present (ADR-0014), so this is a pure
3762
+ * cast with no runtime guard. Used by those measures' definition adapters so
3763
+ * the generators keep their precise tuple parameter (ADR-0013).
3377
3764
  */
3378
- function arcKnockoutHalfFraction(halfExtentMeters, radius, sweepAngle) {
3379
- const arcLength = radius * Math.abs(sweepAngle);
3380
- if (!(arcLength > 1e-6)) return .5;
3381
- return Math.min(.5, Math.max(0, halfExtentMeters) / arcLength);
3382
- }
3383
- /** The end-label text for a phase-line naming, or `""` when unnamed. */
3384
- function phaseLineLabelText(name, includePrefix) {
3385
- if (!name) return "";
3386
- return includePrefix ? `PL ${name}` : name;
3765
+ function asThreePoints(points) {
3766
+ return [
3767
+ points[0],
3768
+ points[1],
3769
+ points[2]
3770
+ ];
3387
3771
  }
3772
+ const DEFAULT_AMBUSH_OPTIONS = {
3773
+ arrowheadLengthRatio: .15,
3774
+ arrowheadWidthRatio: .5,
3775
+ hatchLengthRatio: .15,
3776
+ hatchCount: 5,
3777
+ curveDepthRatio: .25,
3778
+ arcSegments: 30
3779
+ };
3780
+ const AMBUSH_METADATA = {
3781
+ id: "ambush",
3782
+ name: "Ambush",
3783
+ description: "A variation of attack from concealed positions against a moving or temporarily halted enemy.",
3784
+ entity: "Maneuver Lines",
3785
+ entityType: "Ambush",
3786
+ value: "141700",
3787
+ minCoordinates: 3,
3788
+ maxCoordinates: 3,
3789
+ geometry: "line",
3790
+ geometryTypes: ["MultiLineString"],
3791
+ paints: {
3792
+ stroke: true,
3793
+ fill: "none",
3794
+ text: false
3795
+ },
3796
+ drawRule: "Line29",
3797
+ params: [
3798
+ {
3799
+ key: "arrowheadLengthRatio",
3800
+ presentationTier: "advanced",
3801
+ label: "Arrowhead length",
3802
+ description: "Arrowhead length as a ratio of the chord span",
3803
+ type: "number",
3804
+ min: .01,
3805
+ max: .5,
3806
+ step: .01
3807
+ },
3808
+ {
3809
+ key: "arrowheadWidthRatio",
3810
+ presentationTier: "advanced",
3811
+ label: "Arrowhead width",
3812
+ description: "Arrowhead base width as a multiple of the arrowhead length",
3813
+ type: "number",
3814
+ min: .05,
3815
+ max: 2,
3816
+ step: .01
3817
+ },
3818
+ {
3819
+ key: "hatchLengthRatio",
3820
+ presentationTier: "advanced",
3821
+ label: "Hatch length",
3822
+ description: "Length of the parallel hatches as a fraction of the chord span",
3823
+ type: "number",
3824
+ min: .01,
3825
+ max: .5,
3826
+ step: .01
3827
+ },
3828
+ {
3829
+ key: "hatchCount",
3830
+ presentationTier: "advanced",
3831
+ label: "Hatch count",
3832
+ description: "Number of parallel hatches spaced along the curve",
3833
+ type: "number",
3834
+ min: 0,
3835
+ max: 20,
3836
+ step: 1
3837
+ },
3838
+ {
3839
+ key: "curveDepthRatio",
3840
+ presentationTier: "advanced",
3841
+ label: "Curve depth",
3842
+ description: "How far the back line bends toward the tip, as a fraction of the chord span",
3843
+ type: "number",
3844
+ min: 0,
3845
+ max: 1,
3846
+ step: .01
3847
+ },
3848
+ {
3849
+ key: "arcSegments",
3850
+ label: "Arc segments",
3851
+ description: "Number of segments used to render the curve",
3852
+ type: "number",
3853
+ presentationTier: "advanced",
3854
+ min: 4,
3855
+ max: 96,
3856
+ step: 1
3857
+ }
3858
+ ]
3859
+ };
3860
+ const quadBezier = (t, a, b, c) => {
3861
+ const it = 1 - t;
3862
+ 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]];
3863
+ };
3388
3864
  /**
3389
- * Places a Field N hostile marker beyond each end of `verts`, following the
3390
- * same tangent, rotation, anchor, size, and padding conventions as
3391
- * {@link pushEndLabels}. When another beyond-tip label is present, its
3392
- * estimated width is added so the hostile marker remains outermost.
3865
+ * Generates a GeoJSON FeatureCollection for an AMBUSH tactical symbol.
3866
+ * Handles internal projection to maintain strict perpendicularity and parallel hatching.
3867
+ *
3868
+ * @param coordinates - [PT 1 (Target Tip), PT 2 (Back Chord End), PT 3 (Back Chord End)]
3869
+ * @param options - Geometric and styling parameters
3870
+ * @returns GeoJSON FeatureCollection containing LineString components
3393
3871
  */
3394
- function pushHostileEndLabels(out, verts, hostileText, options = {}, occupiedTextForEnd = "", context) {
3395
- if (!hostileText) return;
3396
- const frames = endFrames(verts);
3397
- const sizeProps = labelSizeProps(options);
3398
- for (const end of ["start", "end"]) {
3399
- const frame = frames[end];
3400
- if (!frame) continue;
3401
- const occupiedText = typeof occupiedTextForEnd === "string" ? occupiedTextForEnd : occupiedTextForEnd(end);
3402
- const metrics = resolveTextMetrics(occupiedText || hostileText, options, context);
3403
- const clearance = (END_LABEL_CLEARANCE_RATIO + Math.max(0, options.labelPadding ?? 0)) * metrics.height;
3404
- const distance = clearance + (occupiedText ? metrics.width + clearance : 0);
3405
- pushLabel(out, vecAdd(frame.point, vecScale(frame.along, distance)), hostileText, labelRotationAlong(frame.along), sizeProps, frame.along[0] >= 0 ? "start" : "end", "N", `label:N:${end}`);
3872
+ function createAmbushSymbol(coordinates, options = {}) {
3873
+ const { arrowheadLengthRatio, arrowheadWidthRatio, hatchLengthRatio, hatchCount, curveDepthRatio, arcSegments } = {
3874
+ ...DEFAULT_AMBUSH_OPTIONS,
3875
+ ...options
3876
+ };
3877
+ const safeArcSegments = Math.max(1, Math.floor(arcSegments));
3878
+ const safeHatchCount = Math.max(0, Math.floor(hatchCount));
3879
+ const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
3880
+ const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
3881
+ const safeHatchLengthRatio = Math.max(0, hatchLengthRatio);
3882
+ const safeCurveDepthRatio = Math.max(0, curveDepthRatio);
3883
+ const [p1, p2, p3] = coordinates.map((coord) => project(coord[0], coord[1]));
3884
+ const mid = vecScale(vecAdd(p2, p3), .5);
3885
+ const v23 = vecSub(p3, p2);
3886
+ const span = vecMag(v23);
3887
+ if (span < 1e-6) return {
3888
+ type: "FeatureCollection",
3889
+ features: []
3890
+ };
3891
+ const dir23 = vecNorm(v23);
3892
+ const n = [-dir23[1], dir23[0]];
3893
+ const dist = vecDot(vecSub(p1, mid), n);
3894
+ const side = dist >= 0 ? 1 : -1;
3895
+ const ctrl = vecAdd(mid, vecScale(n, side * span * safeCurveDepthRatio));
3896
+ const shaftOrigin = quadBezier(.5, p2, ctrl, p3);
3897
+ const adjP1 = vecAdd(shaftOrigin, vecScale(n, side * Math.max(0, Math.abs(dist) - span * safeCurveDepthRatio * .5)));
3898
+ const lines = [];
3899
+ lines.push([shaftOrigin, adjP1]);
3900
+ const hL = span * safeArrowheadLengthRatio;
3901
+ const hw = hL * safeArrowheadWidthRatio;
3902
+ const hBase = vecSub(adjP1, vecScale(n, side * hL));
3903
+ const perp = [-n[1], n[0]];
3904
+ lines.push([
3905
+ vecAdd(hBase, vecScale(perp, hw)),
3906
+ adjP1,
3907
+ vecSub(hBase, vecScale(perp, hw))
3908
+ ]);
3909
+ const curve = [];
3910
+ for (let i = 0; i <= safeArcSegments; i++) {
3911
+ const t = i / safeArcSegments;
3912
+ curve.push(quadBezier(t, p2, ctrl, p3));
3913
+ }
3914
+ lines.push(curve);
3915
+ const hatchLengthActual = span * safeHatchLengthRatio;
3916
+ for (let i = 1; i <= safeHatchCount; i++) {
3917
+ const pt = quadBezier(i / (safeHatchCount + 1), p2, ctrl, p3);
3918
+ lines.push([pt, vecSub(pt, vecScale(n, side * hatchLengthActual))]);
3406
3919
  }
3920
+ return {
3921
+ type: "FeatureCollection",
3922
+ features: [{
3923
+ type: "Feature",
3924
+ properties: {},
3925
+ geometry: {
3926
+ type: "MultiLineString",
3927
+ coordinates: lines.map((line) => line.map((c) => unproject(c[0], c[1])))
3928
+ }
3929
+ }]
3930
+ };
3407
3931
  }
3932
+ const AMBUSH = defineControlMeasure({
3933
+ metadata: AMBUSH_METADATA,
3934
+ generator: (controlPoints, options) => createAmbushSymbol(asThreePoints(controlPoints), options),
3935
+ defaultOptions: DEFAULT_AMBUSH_OPTIONS,
3936
+ rule: ambushDrawRule
3937
+ });
3938
+ //#endregion
3939
+ //#region src/internal/gapped-line.ts
3408
3940
  /**
3409
- * Resolves the {@link AboveEndFrame} at each tip of `verts` (projected meters):
3410
- * a base point inset from the tip toward the interior, the segment's own
3411
- * natural `perp` (so "above" = `+perp` reads the same at both ends), the
3412
- * one-row perpendicular `offset`, the along-line `rotation`, and the
3413
- * interior-growing `textAnchor`. Shared by {@link pushEndLabelsAbove} (one
3414
- * label per tip, at `base + perp·offset`) and generic-c2-line's dual
3415
- * above/below amplifier labels (`base ± perp·offset`), so both build on the
3416
- * same tangent/inset/anchor math instead of re-deriving it.
3941
+ * Pushes the arc-length gap `[g0, g1]` onto `gaps`, normalizing it onto
3942
+ * `[0, total)` and splitting it into two intervals when it straddles a ring
3943
+ * seam ({@link buildGappedLine} walks a flat array and does not wrap on its
3944
+ * own). Use this for closed rings; open polylines can push {@link ArcGap}s
3945
+ * directly.
3417
3946
  */
3418
- function aboveEndFrames(verts, options) {
3419
- const tangents = endTangents(verts);
3420
- const inset = resolveLabelOffsetMeters(options, ABOVE_LABEL_INSET_RATIO);
3421
- const offset = resolveLabelOffsetMeters(options, ABOVE_LABEL_OFFSET_RATIO + Math.max(0, options.labelPadding ?? 0));
3422
- const frames = {};
3423
- for (const end of ["start", "end"]) {
3424
- const tangent = tangents[end];
3425
- if (!tangent) continue;
3426
- frames[end] = {
3427
- base: vecAdd(tangent.point, vecScale(tangent.interior, inset)),
3428
- perp: tangent.perp,
3429
- offset,
3430
- rotation: labelRotationAlong(tangent.interior),
3431
- textAnchor: tangent.outwardX >= 0 ? "end" : "start"
3432
- };
3947
+ function pushWrappedGap(gaps, g0, g1, total) {
3948
+ if (g1 - g0 >= total) {
3949
+ gaps.push({
3950
+ g0: 0,
3951
+ g1: total
3952
+ });
3953
+ return;
3954
+ }
3955
+ const lo = (g0 % total + total) % total;
3956
+ const hi = (g1 % total + total) % total;
3957
+ if (lo <= hi) gaps.push({
3958
+ g0: lo,
3959
+ g1: hi
3960
+ });
3961
+ else {
3962
+ gaps.push({
3963
+ g0: lo,
3964
+ g1: total
3965
+ });
3966
+ gaps.push({
3967
+ g0: 0,
3968
+ g1: hi
3969
+ });
3433
3970
  }
3434
- return frames;
3435
3971
  }
3436
- /**
3437
- * Places one label above each end of `verts` (projected meters), inset
3438
- * slightly from the tip and anchored toward the line's interior — the MIL-
3439
- * STD-2525E template convention for fixed end labels like "LL"/"HOL"/"BHL"
3440
- * (as opposed to Phase Line's "beyond the tip" placement, see
3441
- * {@link pushEndLabels}). Placement comes from {@link aboveEndFrames}, which
3442
- * generic-c2-line's H/T-above + W/W1-below labels share. `textForEnd` supplies
3443
- * each end's text — a shared string, or a function keyed by which end; an empty
3444
- * string skips that end.
3445
- */
3446
- function pushEndLabelsAbove(out, verts, textForEnd, options = {}) {
3447
- const frames = aboveEndFrames(verts, options);
3448
- const sizeProps = labelSizeProps(options);
3449
- for (const end of ["start", "end"]) {
3450
- const frame = frames[end];
3451
- if (!frame) continue;
3452
- const text = typeof textForEnd === "string" ? textForEnd : textForEnd(end);
3453
- if (text.length === 0) continue;
3454
- pushLabel(out, vecAdd(frame.base, vecScale(frame.perp, frame.offset)), text, frame.rotation, sizeProps, frame.textAnchor);
3455
- }
3972
+ /** Whether the global arc position `arc` falls inside any of `gaps` (each
3973
+ * already normalized onto `[0, total)` by {@link pushWrappedGap}, so a plain
3974
+ * per-gap interval test suffices). Shared by the boundary-decoration measures
3975
+ * that suppress a glyph/tic/barb sitting on top of a masking gap. */
3976
+ function arcInGaps(arc, gaps) {
3977
+ return gaps.some((g) => arc >= g.g0 && arc <= g.g1);
3978
+ }
3979
+ /** Appends `position` unless it coincides with the buffer's current tail. */
3980
+ function appendUnique(buffer, position) {
3981
+ const tail = buffer[buffer.length - 1];
3982
+ if (tail && Math.abs(tail[0] - position[0]) < 1e-9 && Math.abs(tail[1] - position[1]) < 1e-9) return;
3983
+ buffer.push(position);
3456
3984
  }
3457
3985
  /**
3458
- * Convenience for measures that are just a polyline with a fixed label at
3459
- * each end: builds the LineString through `positions` plus its end labels
3460
- * (via {@link pushEndLabels} or {@link pushEndLabelsAbove}, per `placement`)
3461
- * as a complete FeatureCollection.
3986
+ * Builds the polyline as MultiLineString rings, splitting it with a gap at each
3987
+ * echelon position. `gaps` holds global arc-length intervals (distance from the
3988
+ * start of the whole polyline) to carve out — a single gap can span several
3989
+ * short segments, which matters when the line has been smoothed into many
3990
+ * samples. Works for both open polylines and closed rings (pass a vertex array
3991
+ * whose first and last positions coincide).
3462
3992
  */
3463
- function fixedLabelLineFeatures(positions, options, textForEnd, hostileText, context) {
3464
- const { part, placement = "beyond", phaseLineName, includePrefix = true, lineStyle, smooth, smoothResolution, ...sizeOptions } = options;
3465
- const verts = smoothLineVerts(positions.map((p) => project(p[0], p[1])), {
3466
- smooth,
3467
- smoothResolution
3468
- }, 12);
3469
- const lineCoordinates = smooth ? verts.map((v) => unproject(v[0], v[1])) : positions;
3470
- const features = [{
3471
- type: "Feature",
3472
- properties: lineStyle === void 0 ? { part } : {
3473
- part,
3474
- style: lineStyle
3475
- },
3476
- geometry: {
3477
- type: "LineString",
3478
- coordinates: lineCoordinates
3479
- }
3480
- }];
3481
- const labelFeatures = [];
3482
- if (placement === "above") pushEndLabelsAbove(labelFeatures, verts, textForEnd, sizeOptions);
3483
- else pushEndLabels(labelFeatures, verts, textForEnd, sizeOptions);
3484
- const nameText = phaseLineLabelText(phaseLineName?.trim(), includePrefix);
3485
- if (nameText) pushEndLabels(labelFeatures, verts, nameText, sizeOptions);
3486
- pushHostileEndLabels(labelFeatures, verts, hostileText, sizeOptions, nameText || (placement === "beyond" ? textForEnd : ""), context);
3487
- features.push(...labelFeatures);
3488
- return {
3489
- type: "FeatureCollection",
3490
- features
3993
+ function buildGappedLine(verts, gaps) {
3994
+ const parts = [];
3995
+ let buffer = [];
3996
+ const flush = () => {
3997
+ if (buffer.length >= 2) parts.push(buffer);
3998
+ buffer = [];
3491
3999
  };
4000
+ if (verts.length === 0) return parts;
4001
+ const merged = [];
4002
+ for (const g of [...gaps].sort((x, y) => x.g0 - y.g0)) {
4003
+ const last = merged[merged.length - 1];
4004
+ if (last && g.g0 <= last.g1) last.g1 = Math.max(last.g1, g.g1);
4005
+ else merged.push({ ...g });
4006
+ }
4007
+ appendUnique(buffer, unproject(verts[0][0], verts[0][1]));
4008
+ let dist = 0;
4009
+ for (let i = 0; i < verts.length - 1; i++) {
4010
+ const a = verts[i];
4011
+ const b = verts[i + 1];
4012
+ const seg = vecSub(b, a);
4013
+ const L = vecMag(seg);
4014
+ if (L < 1e-6) continue;
4015
+ const segStart = dist;
4016
+ const segEnd = dist + L;
4017
+ const along = vecNorm(seg);
4018
+ const pointAt = (globalD) => {
4019
+ const q = vecAdd(a, vecScale(along, globalD - segStart));
4020
+ return unproject(q[0], q[1]);
4021
+ };
4022
+ for (const g of merged) {
4023
+ if (g.g1 <= segStart || g.g0 >= segEnd) continue;
4024
+ const g0 = Math.max(segStart, g.g0);
4025
+ const g1 = Math.min(segEnd, g.g1);
4026
+ if (g1 <= g0) continue;
4027
+ appendUnique(buffer, pointAt(g0));
4028
+ flush();
4029
+ buffer.push(pointAt(g1));
4030
+ }
4031
+ appendUnique(buffer, unproject(b[0], b[1]));
4032
+ dist = segEnd;
4033
+ }
4034
+ flush();
4035
+ return parts;
3492
4036
  }
3493
4037
  //#endregion
3494
4038
  //#region src/text-amplifiers.ts
@@ -5693,6 +6237,7 @@ const freezeEchelonLabeledAreaAnchor = makeFreezeEchelonAnchor({
5693
6237
  const DEFAULT_BASE_CAMP_OPTIONS = DEFAULT_ECHELON_LABELED_AREA_OPTIONS;
5694
6238
  const BASE_CAMP_METADATA = {
5695
6239
  id: "base-camp",
6240
+ sizePairs: [ECHELON_SIZE_PAIR],
5696
6241
  name: "Base camp",
5697
6242
  description: "An evolving military facility that supports military operations of a deployed unit and provides the necessary support and services for sustained operations.",
5698
6243
  entity: "Command and Control Areas",
@@ -5752,6 +6297,7 @@ const BASE_CAMP = defineControlMeasure({
5752
6297
  const DEFAULT_GUERRILLA_BASE_OPTIONS = DEFAULT_ECHELON_LABELED_AREA_OPTIONS;
5753
6298
  const GUERRILLA_BASE_METADATA = {
5754
6299
  id: "guerrilla-base",
6300
+ sizePairs: [ECHELON_SIZE_PAIR],
5755
6301
  name: "Guerrilla base",
5756
6302
  description: "A temporary site where guerrilla installations, headquarters, and units are located.",
5757
6303
  entity: "Command and Control Areas",
@@ -5997,6 +6543,15 @@ const ANTITANK_WALL_PARAMS = [...ANTITANK_DITCH_PARAMS, {
5997
6543
  const DEFAULT_TOOTH_HEIGHT = 50;
5998
6544
  const DEFAULT_TOOTH_HEIGHT_PIXELS = 10;
5999
6545
  const DEFAULT_TOOTH_WIDTH_RATIO$1 = 1.15;
6546
+ /** Shared by both ditch variants and by the antitank wall built on the same teeth. */
6547
+ const TOOTH_HEIGHT_SIZE_PAIR = {
6548
+ id: "tooth-height",
6549
+ label: "Tooth height",
6550
+ pixels: "toothHeightPixels",
6551
+ meters: "toothHeight",
6552
+ defaultUnit: "m",
6553
+ rendering: "geometry"
6554
+ };
6000
6555
  const DEFAULT_ANTITANK_DITCH_OPTIONS = {
6001
6556
  toothHeight: DEFAULT_TOOTH_HEIGHT,
6002
6557
  toothHeightPixels: DEFAULT_TOOTH_HEIGHT_PIXELS,
@@ -6006,6 +6561,7 @@ const DEFAULT_ANTITANK_DITCH_OPTIONS = {
6006
6561
  };
6007
6562
  const ANTITANK_DITCH_UNDER_CONSTRUCTION_METADATA = {
6008
6563
  id: "antitank-ditch-under-construction",
6564
+ sizePairs: [TOOTH_HEIGHT_SIZE_PAIR],
6009
6565
  name: "Ditch - Under Construction",
6010
6566
  description: "Antitank ditch obstacle under construction.",
6011
6567
  entity: "Protection Lines",
@@ -6028,6 +6584,7 @@ const ANTITANK_DITCH_UNDER_CONSTRUCTION_METADATA = {
6028
6584
  };
6029
6585
  const ANTITANK_DITCH_COMPLETED_METADATA = {
6030
6586
  id: "antitank-ditch-completed",
6587
+ sizePairs: [TOOTH_HEIGHT_SIZE_PAIR],
6031
6588
  name: "Ditch - Completed",
6032
6589
  description: "Completed antitank ditch obstacle.",
6033
6590
  entity: "Protection Lines",
@@ -6161,6 +6718,7 @@ const DEFAULT_ANTITANK_WALL_OPTIONS = {
6161
6718
  };
6162
6719
  const ANTITANK_WALL_METADATA = {
6163
6720
  id: "antitank-wall",
6721
+ sizePairs: [TOOTH_HEIGHT_SIZE_PAIR],
6164
6722
  name: "Antitank Wall",
6165
6723
  description: "Antitank wall obstacle.",
6166
6724
  entity: "Protection Lines",
@@ -6317,7 +6875,7 @@ function createAttackByFire(coordinates, options = {}) {
6317
6875
  const p2Back = vecAdd(p2, vecScale(dirBackLeft, backLen));
6318
6876
  const p3Back = vecAdd(p3, vecScale(dirBackRight, backLen));
6319
6877
  const headWidth = lenShaft * arrowheadWidthRatio;
6320
- const headFeatures = createArrowHead$2(pTip, dirShaft, headWidth, headWidth);
6878
+ const headFeatures = createArrowHead$1(pTip, dirShaft, headWidth, headWidth);
6321
6879
  return {
6322
6880
  type: "FeatureCollection",
6323
6881
  features: [
@@ -6360,7 +6918,7 @@ function createAttackByFire(coordinates, options = {}) {
6360
6918
  ]
6361
6919
  };
6362
6920
  }
6363
- function createArrowHead$2(tip, dir, length, width) {
6921
+ function createArrowHead$1(tip, dir, length, width) {
6364
6922
  const norm = [-dir[1], dir[0]];
6365
6923
  const base = vecSub(tip, vecScale(dir, length));
6366
6924
  const left = vecAdd(base, vecScale(norm, width / 2));
@@ -6625,6 +7183,7 @@ const DEFAULT_BATTLE_POSITION_OPTIONS = {
6625
7183
  };
6626
7184
  const BATTLE_POSITION_METADATA = {
6627
7185
  id: "battle-position",
7186
+ sizePairs: [ECHELON_SIZE_PAIR],
6628
7187
  name: "Battle Position",
6629
7188
  description: "A defensive location oriented on a likely enemy avenue of approach, labeled with the echelon of the occupying unit.",
6630
7189
  entity: "Maneuver Areas",
@@ -6768,6 +7327,7 @@ const BATTLE_POSITION_PREPARED_DASH = [6, 4];
6768
7327
  const DEFAULT_BATTLE_POSITION_PREPARED_OPTIONS = { ...DEFAULT_BATTLE_POSITION_OPTIONS };
6769
7328
  const BATTLE_POSITION_PREPARED_METADATA = {
6770
7329
  id: "battle-position-prepared",
7330
+ sizePairs: [ECHELON_SIZE_PAIR],
6771
7331
  name: "Battle Position Prepared (P) but not Occupied",
6772
7332
  description: "A battle position that has been prepared for occupation but is not yet occupied, drawn with a dashed boundary and a (P)-prefixed designation.",
6773
7333
  entity: "Maneuver Areas",
@@ -7735,6 +8295,7 @@ const DEFAULT_BOUNDARY_OPTIONS = {
7735
8295
  };
7736
8296
  const BOUNDARY_METADATA = {
7737
8297
  id: "boundary",
8298
+ sizePairs: [ECHELON_SIZE_PAIR],
7738
8299
  name: "Boundary",
7739
8300
  description: "Delineates the area of responsibility between two adjacent units, labeled with the echelon and the designators of the units it separates.",
7740
8301
  entity: "Command and Control Lines",
@@ -8152,6 +8713,7 @@ const DEFAULT_GENERIC_C2_LINE_OPTIONS = {
8152
8713
  };
8153
8714
  const GENERIC_C2_LINE_METADATA = {
8154
8715
  id: "generic-c2-line",
8716
+ sizePairs: [ECHELON_SIZE_PAIR],
8155
8717
  name: "Generic command and control line",
8156
8718
  description: "Illustrative command-and-control-style line (not part of MIL-STD-2525E) carrying optional echelon glyphs, additional-information/designation text above it, and an effective date-time-group window below it.",
8157
8719
  entity: "Command and Control Lines",
@@ -8357,130 +8919,6 @@ const GENERIC_C2_LINE = defineControlMeasure({
8357
8919
  }
8358
8920
  });
8359
8921
  //#endregion
8360
- //#region src/generators/cm34-mission-tasks/shared.ts
8361
- /**
8362
- * Builds an open chevron arrowhead (left → tip → right) as unprojected
8363
- * coordinates. The tip sits at `tip`, pointing along `dir`, with the base set
8364
- * back by `length` and spanning `width` perpendicular to the shaft. Mirrors the
8365
- * `createArrowHead` helper used by the attack-family generators.
8366
- */
8367
- function createArrowHead$1(tip, dir, length, width) {
8368
- const norm = [-dir[1], dir[0]];
8369
- const base = vecSub(tip, vecScale(dir, length));
8370
- const left = vecAdd(base, vecScale(norm, width / 2));
8371
- const right = vecSub(base, vecScale(norm, width / 2));
8372
- return [
8373
- unproject(left[0], left[1]),
8374
- unproject(tip[0], tip[1]),
8375
- unproject(right[0], right[1])
8376
- ];
8377
- }
8378
- /**
8379
- * Builds the glyph label as a Point feature, forwarding the resolved size
8380
- * anchor (`textSizeMeters`/`textSizePixels`, ADR-0028) to adapters for
8381
- * styling. `point` is in projected meters; the rotation must already be in
8382
- * the generator's feature-rotation convention. Shared by every mission-task
8383
- * measure that centers a glyph on its symbol.
8384
- */
8385
- function createLabelFeature(point, text, rotation, labelSize = {}) {
8386
- return {
8387
- type: "Feature",
8388
- properties: {
8389
- part: "label",
8390
- labelPlacement: false,
8391
- text,
8392
- rotation,
8393
- ...labelSizeProps(labelSize)
8394
- },
8395
- geometry: {
8396
- type: "Point",
8397
- coordinates: unproject(point[0], point[1])
8398
- }
8399
- };
8400
- }
8401
- /**
8402
- * Shared scaffold for the "bracket" mission-task symbols (breach, bypass,
8403
- * canalize). Owns the three-sided bracket: a front opening (PT. 1 / PT. 2), a
8404
- * rear line parallel to it through PT. 3, and a glyph centered on the rear line.
8405
- * Each measure supplies only its end caps and label glyph via
8406
- * {@link BracketSymbolConfig}.
8407
- *
8408
- * Input Points:
8409
- * - PT. 1: Front opening endpoint (top/left)
8410
- * - PT. 2: Front opening endpoint (bottom/right) - defines height with PT. 1
8411
- * - PT. 3: Rear point - defines length and orientation
8412
- *
8413
- * Geometric Construction:
8414
- * 1. PT. 1 and PT. 2 define the front opening (facing enemy/objective)
8415
- * 2. A parallel line is projected through PT. 3 with identical height
8416
- * 3. LineString follows: PT. 1 → Rear Top → Rear Bottom → PT. 2
8417
- * 4. Measure-specific end caps at PT. 1 and PT. 2
8418
- * 5. Label point at midpoint of rear line (styled with OpenLayers Text)
8419
- *
8420
- * @param coordinates - [PT. 1, PT. 2, PT. 3] as Position arrays
8421
- * @param config - Per-measure end caps, label glyph, and sizing
8422
- * @returns GeoJSON FeatureCollection containing MultiLineString and Point features
8423
- */
8424
- function createBracketSymbol(coordinates, config, context) {
8425
- const [c1, c2, c3] = coordinates;
8426
- const p1 = project(c1[0], c1[1]);
8427
- const p2 = project(c2[0], c2[1]);
8428
- const p3 = project(c3[0], c3[1]);
8429
- const vFront = vecSub(p2, p1);
8430
- const frontLength = vecMag(vFront);
8431
- if (frontLength < 1e-6) return {
8432
- type: "FeatureCollection",
8433
- features: []
8434
- };
8435
- const dirFront = vecNorm(vFront);
8436
- const frontMidpoint = vecScale(vecAdd(p1, p2), .5);
8437
- const vToRear = vecSub(p3, frontMidpoint);
8438
- const perpDir = [-dirFront[1], dirFront[0]];
8439
- const perpDistance = vToRear[0] * perpDir[0] + vToRear[1] * perpDir[1];
8440
- const halfFront = vecScale(dirFront, frontLength / 2);
8441
- const rearMidpoint = vecAdd(frontMidpoint, vecScale(perpDir, perpDistance));
8442
- const rearTop = vecSub(rearMidpoint, halfFront);
8443
- const rearBottom = vecAdd(rearMidpoint, halfFront);
8444
- const rearLineRotation = Math.atan2(dirFront[1], dirFront[0]);
8445
- const renderedLabelRotation = Math.PI - (rearLineRotation - Math.PI / 2);
8446
- const labelRotation = normalizeRadians(Math.PI - keepTextLeftToRight(renderedLabelRotation));
8447
- const gapHalf = Math.min(frontLength / 2, labelKnockoutHalfExtent(config.labelText, dirFront, labelRotation, config.labelOptions ?? {}, context));
8448
- const gapPt1 = vecAdd(rearMidpoint, vecScale(dirFront, -gapHalf));
8449
- const gapPt2 = vecAdd(rearMidpoint, vecScale(dirFront, gapHalf));
8450
- const [topCap, bottomCap] = config.buildEndCaps({
8451
- p1,
8452
- p2,
8453
- dirFront,
8454
- perpDir,
8455
- frontLength,
8456
- rearTop
8457
- });
8458
- return {
8459
- type: "FeatureCollection",
8460
- features: [{
8461
- type: "Feature",
8462
- properties: { part: config.part },
8463
- geometry: {
8464
- type: "MultiLineString",
8465
- coordinates: [
8466
- topCap,
8467
- [
8468
- unproject(p1[0], p1[1]),
8469
- unproject(rearTop[0], rearTop[1]),
8470
- unproject(gapPt1[0], gapPt1[1])
8471
- ],
8472
- [
8473
- unproject(gapPt2[0], gapPt2[1]),
8474
- unproject(rearBottom[0], rearBottom[1]),
8475
- unproject(p2[0], p2[1])
8476
- ],
8477
- bottomCap
8478
- ]
8479
- }
8480
- }, createLabelFeature(rearMidpoint, config.labelText, labelRotation, config.labelOptions)]
8481
- };
8482
- }
8483
- //#endregion
8484
8922
  //#region src/generators/cm34-mission-tasks/block.ts
8485
8923
  const DEFAULT_STEM_RATIO = 1.5;
8486
8924
  const DEFAULT_BLOCK_MISSION_TASK_OPTIONS = { labelPadding: DEFAULT_LABEL_KNOCKOUT_PADDING };
@@ -8721,7 +9159,7 @@ function createBypassSymbol(coordinates, options = {}, _textAmplifiers = {}, con
8721
9159
  buildEndCaps: ({ p1, p2, frontLength, rearTop }) => {
8722
9160
  const arrowheadLength = frontLength * arrowheadLengthRatio;
8723
9161
  const arrowDir = vecNorm(vecSub(p1, rearTop));
8724
- return [createArrowHead$1(p1, arrowDir, arrowheadLength, arrowheadLength), createArrowHead$1(p2, arrowDir, arrowheadLength, arrowheadLength)];
9162
+ return [createArrowHead$2(p1, arrowDir, arrowheadLength, arrowheadLength), createArrowHead$2(p2, arrowDir, arrowheadLength, arrowheadLength)];
8725
9163
  }
8726
9164
  }, context);
8727
9165
  }
@@ -9607,6 +10045,14 @@ const DEFAULT_GENERIC_TEXT_OPTIONS = {
9607
10045
  };
9608
10046
  const GENERIC_TEXT_METADATA = {
9609
10047
  id: "text",
10048
+ sizePairs: [{
10049
+ id: "size",
10050
+ label: "Size",
10051
+ pixels: "sizePixels",
10052
+ meters: "sizeMeters",
10053
+ defaultUnit: "px",
10054
+ rendering: "text"
10055
+ }],
9610
10056
  name: "Text",
9611
10057
  description: "A non-doctrinal free-standing text label anchored at a single point.",
9612
10058
  entity: "Generic Graphics",
@@ -10007,7 +10453,7 @@ function createClearSymbol(coordinates, options = {}, _textAmplifiers = {}, cont
10007
10453
  lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
10008
10454
  lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
10009
10455
  } else if (shaftLength > 1e-6) lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(tip[0], tip[1])]);
10010
- lines.push(createArrowHead$1(tip, arrowDir, arrowheadLength, arrowheadWidth));
10456
+ lines.push(createArrowHead$2(tip, arrowDir, arrowheadLength, arrowheadWidth));
10011
10457
  });
10012
10458
  const mainFeature = {
10013
10459
  type: "Feature",
@@ -10168,7 +10614,7 @@ function createIsolateVariantSymbol(coordinates, options, config, context = {})
10168
10614
  const barbLength = radius * barbLengthRatio;
10169
10615
  const lines = [sampleArc(0, labelSweep - labelHalfGap), sampleArc(labelSweep + labelHalfGap, arcSpan)];
10170
10616
  const tipDir = [Math.sin(endAngle), -Math.cos(endAngle)];
10171
- lines.push(createArrowHead$1(pointOnCircle(endAngle), tipDir, barbLength, barbLength));
10617
+ lines.push(createArrowHead$2(pointOnCircle(endAngle), tipDir, barbLength, barbLength));
10172
10618
  const halfWidth = Math.asin(Math.min(1, barbLength / 2 / radius));
10173
10619
  const tipRadius = Math.max(0, radius - barbLength);
10174
10620
  const steps = Math.max(1, barbCount);
@@ -11176,7 +11622,7 @@ function createDelayTaskSymbol(coordinates, config, options, context) {
11176
11622
  const lines = [
11177
11623
  [unproject(p1[0], p1[1]), unproject(gapStart[0], gapStart[1])],
11178
11624
  [unproject(gapEnd[0], gapEnd[1]), unproject(p2[0], p2[1])],
11179
- createArrowHead$1(p1, vecScale(dirLine, -1), lineLength * (options.arrowheadLengthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadLengthRatio), lineLength * (options.arrowheadWidthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadWidthRatio))
11625
+ createArrowHead$2(p1, vecScale(dirLine, -1), lineLength * (options.arrowheadLengthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadLengthRatio), lineLength * (options.arrowheadWidthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadWidthRatio))
11180
11626
  ];
11181
11627
  if (diameter >= 1e-6) lines.push(createSemicircleArc(p2, diameterEnd, dirLine, options.arcSegments ?? DEFAULT_DELAY_OPTIONS.arcSegments));
11182
11628
  return {
@@ -11259,8 +11705,8 @@ function createHairpinSymbol(coordinates, config, options = {}) {
11259
11705
  ...upperArm,
11260
11706
  createSemicircleArc(p2, p3, upperDirection, options.arcSegments ?? DEFAULT_DELAY_OPTIONS.arcSegments),
11261
11707
  [unproject(p4[0], p4[1]), unproject(p3[0], p3[1])],
11262
- createArrowHead$1(p1, vecScale(upperDirection, -1), upperLength * arrowheadLengthRatio, upperLength * arrowheadWidthRatio),
11263
- createArrowHead$1(p3, lowerDirection, lowerLength * arrowheadLengthRatio, lowerLength * arrowheadWidthRatio)
11708
+ createArrowHead$2(p1, vecScale(upperDirection, -1), upperLength * arrowheadLengthRatio, upperLength * arrowheadWidthRatio),
11709
+ createArrowHead$2(p3, lowerDirection, lowerLength * arrowheadLengthRatio, lowerLength * arrowheadWidthRatio)
11264
11710
  ]
11265
11711
  }
11266
11712
  }, label]
@@ -11496,12 +11942,12 @@ function createDisruptMissionTaskSymbol(coordinates, options = {}, _textAmplifie
11496
11942
  coordinates: [
11497
11943
  [p1LonLat, p2LonLat],
11498
11944
  [[...p1LonLat], unproject(topTip[0], topTip[1])],
11499
- createArrowHead$1(topTip, arrowDirection, headLength, headWidth),
11945
+ createArrowHead$2(topTip, arrowDirection, headLength, headWidth),
11500
11946
  [unproject(rearEnd[0], rearEnd[1]), unproject(gapStart[0], gapStart[1])],
11501
11947
  [unproject(gapEnd[0], gapEnd[1]), unproject(middleTip[0], middleTip[1])],
11502
- createArrowHead$1(middleTip, arrowDirection, headLength, headWidth),
11948
+ createArrowHead$2(middleTip, arrowDirection, headLength, headWidth),
11503
11949
  [[...p2LonLat], unproject(bottomTip[0], bottomTip[1])],
11504
- createArrowHead$1(bottomTip, arrowDirection, headLength, headWidth)
11950
+ createArrowHead$2(bottomTip, arrowDirection, headLength, headWidth)
11505
11951
  ]
11506
11952
  }
11507
11953
  }, createLabelFeature(labelPoint, "D", labelRotation, {
@@ -11607,7 +12053,7 @@ function createEnvelopment(coordinates, options = {}, _textAmplifiers = {}, cont
11607
12053
  let bulgeRadius = [-startRadius[1], startRadius[0]];
11608
12054
  if (vecDot(vecSub(p4, center), bulgeRadius) < 0) bulgeRadius = vecScale(bulgeRadius, -1);
11609
12055
  const arc = sampleProjectedArc(center, startRadius, bulgeRadius, 0, Math.PI, normalizeArcResolution(options.resolution) / 2);
11610
- const arrowhead = createArrowHead$1(p3, vecNorm(vecScale(bulgeRadius, -1)), diameter * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadLengthRatio), diameter * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadWidthRatio));
12056
+ const arrowhead = createArrowHead$2(p3, vecNorm(vecScale(bulgeRadius, -1)), diameter * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadLengthRatio), diameter * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_ENVELOPMENT_OPTIONS.arrowheadWidthRatio));
11611
12057
  return {
11612
12058
  type: "FeatureCollection",
11613
12059
  features: [{
@@ -11676,7 +12122,7 @@ function createExploitation(coordinates, _options = {}) {
11676
12122
  features: []
11677
12123
  };
11678
12124
  const direction = vecNorm(axis);
11679
- const chevron = (tip) => createArrowHead$1(tip, direction, finLength * Math.SQRT1_2, finLength * Math.SQRT2);
12125
+ const chevron = (tip) => createArrowHead$2(tip, direction, finLength * Math.SQRT1_2, finLength * Math.SQRT2);
11680
12126
  const [finLeft, , finRight] = chevron(p2);
11681
12127
  return {
11682
12128
  type: "FeatureCollection",
@@ -12046,7 +12492,7 @@ function createInfiltration(coordinates, options = {}, _textAmplifiers = {}, con
12046
12492
  const gapHalf = Math.min(straightLength * Math.min(labelPosition, 1 - labelPosition), labelKnockoutHalfExtent("IN", along, labelRotation, options, context, DEFAULT_INFILTRATION_OPTIONS.labelPadding));
12047
12493
  const gapStart = vecAdd(labelPoint, vecScale(along, -gapHalf));
12048
12494
  const gapEnd = vecAdd(labelPoint, vecScale(along, gapHalf));
12049
- const arrowhead = createArrowHead$1(p3, along, spanLength * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadLengthRatio), spanLength * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadWidthRatio));
12495
+ const arrowhead = createArrowHead$2(p3, along, spanLength * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadLengthRatio), spanLength * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_INFILTRATION_OPTIONS.arrowheadWidthRatio));
12050
12496
  return {
12051
12497
  type: "FeatureCollection",
12052
12498
  features: [{
@@ -13077,7 +13523,7 @@ function createFixMissionTaskSymbol(coordinates, options = {}, _textAmplifiers =
13077
13523
  toPosition(zigzagExit),
13078
13524
  toPosition(tip)
13079
13525
  ],
13080
- createArrowHead$1(tip, along, arrowheadLength, arrowheadWidth)
13526
+ createArrowHead$2(tip, along, arrowheadLength, arrowheadWidth)
13081
13527
  ]
13082
13528
  }
13083
13529
  }, createLabelFeature(labelCenter, "F", labelRotation, options)]
@@ -13110,6 +13556,14 @@ const DEFAULT_FLOT_OPTIONS = {
13110
13556
  };
13111
13557
  const FLOT_METADATA = {
13112
13558
  id: "flot",
13559
+ sizePairs: [{
13560
+ id: "radius",
13561
+ label: "Radius",
13562
+ pixels: "radiusPixels",
13563
+ meters: "radius",
13564
+ defaultUnit: "m",
13565
+ rendering: "geometry"
13566
+ }],
13113
13567
  name: "Forward line of own troops (FLOT)",
13114
13568
  description: "Indicates the most forward positions of friendly forces at a specific time.",
13115
13569
  entity: "Maneuver Lines",
@@ -14280,6 +14734,15 @@ const PROBABLE_LINE_OF_DEPLOYMENT = defineControlMeasure({
14280
14734
  });
14281
14735
  //#endregion
14282
14736
  //#region src/generators/cm29-protection-lines/fortifiedLine.ts
14737
+ /** Shared by the fortified line and the fortified area built from it. */
14738
+ const TOOTH_SIZE_SIZE_PAIR = {
14739
+ id: "tooth-size",
14740
+ label: "Tooth size",
14741
+ pixels: "toothSizePixels",
14742
+ meters: "toothSize",
14743
+ defaultUnit: "m",
14744
+ rendering: "geometry"
14745
+ };
14283
14746
  const DEFAULT_TOOTH_SIZE = 50;
14284
14747
  const DEFAULT_TOOTH_SIZE_PIXELS = 10;
14285
14748
  const DEFAULT_SMOOTH_RESOLUTION$1 = 16;
@@ -14294,6 +14757,7 @@ const DEFAULT_FORTIFIED_LINE_OPTIONS = {
14294
14757
  };
14295
14758
  const FORTIFIED_LINE_METADATA = {
14296
14759
  id: "fortified-line",
14760
+ sizePairs: [TOOTH_SIZE_SIZE_PAIR],
14297
14761
  name: "Fortified Line",
14298
14762
  description: "Fortified line with castellated square-wave boundary.",
14299
14763
  entity: "Protection Lines",
@@ -14451,6 +14915,7 @@ const FORTIFIED_LINE = defineControlMeasure({
14451
14915
  const DEFAULT_FORTIFIED_AREA_OPTIONS = DEFAULT_FORTIFIED_LINE_OPTIONS;
14452
14916
  const FORTIFIED_AREA_METADATA = {
14453
14917
  id: "fortified-area",
14918
+ sizePairs: [TOOTH_SIZE_SIZE_PAIR],
14454
14919
  name: "Fortified Area",
14455
14920
  description: "A specified geographical locality with constructed defensive works as protection against attack.",
14456
14921
  entity: "Maneuver Areas",
@@ -15047,7 +15512,7 @@ function createPenetrateSymbol(coordinates, options = {}, _textAmplifiers = {},
15047
15512
  [coordinates[0], coordinates[1]],
15048
15513
  [unproject(tail[0], tail[1]), unproject(gapStart[0], gapStart[1])],
15049
15514
  [unproject(gapEnd[0], gapEnd[1]), unproject(tip[0], tip[1])],
15050
- createArrowHead$1(tip, arrowDirection, headLength, headWidth)
15515
+ createArrowHead$2(tip, arrowDirection, headLength, headWidth)
15051
15516
  ]
15052
15517
  }
15053
15518
  }, createLabelFeature(labelPoint, "P", labelRotation, {
@@ -15173,7 +15638,7 @@ function createPursuit(coordinates, options = {}, _textAmplifiers = {}, context
15173
15638
  const arc = sampleProjectedArc(center, vecSub(p2, center), vecScale(straightDirection, diameter / 2), 0, Math.PI, normalizeArcResolution(options.resolution) / 2);
15174
15639
  const arrowheadLength = diameter * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_PURSUIT_OPTIONS.arrowheadLengthRatio);
15175
15640
  const arrowheadWidth = diameter * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_PURSUIT_OPTIONS.arrowheadWidthRatio);
15176
- const arrowhead = createArrowHead$1(p3, vecScale(straightDirection, -1), arrowheadLength, arrowheadWidth);
15641
+ const arrowhead = createArrowHead$2(p3, vecScale(straightDirection, -1), arrowheadLength, arrowheadWidth);
15177
15642
  const terminalBarHalf = arrowheadWidth * Math.max(0, options.terminalBarLengthRatio ?? DEFAULT_PURSUIT_OPTIONS.terminalBarLengthRatio) / 2;
15178
15643
  const terminalStart = vecAdd(p3, vecScale(diameterAxis, terminalBarHalf));
15179
15644
  const terminalEnd = vecSub(p3, vecScale(diameterAxis, terminalBarHalf));
@@ -15489,7 +15954,7 @@ function createSecureSymbol(coordinates, options = {}, _textAmplifiers = {}, con
15489
15954
  const lines = [
15490
15955
  sampleArc(0, labelSweep - labelHalfGap),
15491
15956
  sampleArc(labelSweep + labelHalfGap, boundarySpan),
15492
- createArrowHead$1(terminal, tangent, radius * Math.max(0, resolved.arrowheadLengthRatio), radius * Math.max(0, resolved.arrowheadWidthRatio))
15957
+ createArrowHead$2(terminal, tangent, radius * Math.max(0, resolved.arrowheadLengthRatio), radius * Math.max(0, resolved.arrowheadWidthRatio))
15493
15958
  ];
15494
15959
  const labelPoint = arcPointAt(center, radiusVector, bulgeVector, labelSweep);
15495
15960
  return {
@@ -15684,7 +16149,7 @@ function createSeizeLikeSymbol(coordinates, options = {}, label = "S", part = "s
15684
16149
  circle,
15685
16150
  firstArcPart,
15686
16151
  secondArcPart,
15687
- createArrowHead$1(tip, arrowDirection, span * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_SEIZE_OPTIONS.arrowheadLengthRatio), span * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_SEIZE_OPTIONS.arrowheadWidthRatio))
16152
+ createArrowHead$2(tip, arrowDirection, span * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_SEIZE_OPTIONS.arrowheadLengthRatio), span * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_SEIZE_OPTIONS.arrowheadWidthRatio))
15688
16153
  ];
15689
16154
  return {
15690
16155
  type: "FeatureCollection",
@@ -16561,6 +17026,15 @@ const DEFAULT_MINEFIELD_OPTIONS = {
16561
17026
  const DEFAULT_MINEFIELD_SIZE_METERS = 1e3;
16562
17027
  const MINEFIELD_METADATA = {
16563
17028
  id: "minefield",
17029
+ sizePairs: [{
17030
+ id: "size",
17031
+ label: "Size",
17032
+ pixels: "sizePixels",
17033
+ meters: "sizeMeters",
17034
+ defaultUnit: "px",
17035
+ rendering: "geometry",
17036
+ fallbackMeters: DEFAULT_MINEFIELD_SIZE_METERS
17037
+ }],
16564
17038
  name: "Minefield",
16565
17039
  description: "A static point symbol identifying a minefield and its mine type.",
16566
17040
  entity: "Protection Areas",
@@ -17954,6 +18428,7 @@ const DEFAULT_SMOOTH_RESOLUTION = 16;
17954
18428
  const DEFAULT_STRONG_POINT_OPTIONS = { ...DEFAULT_BATTLE_POSITION_OPTIONS };
17955
18429
  const STRONG_POINT_METADATA = {
17956
18430
  id: "strong-point",
18431
+ sizePairs: [ECHELON_SIZE_PAIR],
17957
18432
  name: "Strong Point",
17958
18433
  description: "A key point in a defensive position, usually strongly fortified and heavily armed, around which other positions are grouped for its protection.",
17959
18434
  entity: "Maneuver Areas",
@@ -18698,6 +19173,10 @@ const DEFINITIONS = {
18698
19173
  guard: GUARD,
18699
19174
  isolate: ISOLATE,
18700
19175
  "movement-to-contact": MOVEMENT_TO_CONTACT,
19176
+ destroy: DESTROY,
19177
+ defeat: DEFEAT,
19178
+ neutralize: NEUTRALIZE,
19179
+ suppress: SUPPRESS,
18701
19180
  occupy: OCCUPY,
18702
19181
  penetrate: PENETRATE,
18703
19182
  pursuit: PURSUIT,
@@ -18751,18 +19230,27 @@ const DEFINITIONS = {
18751
19230
  };
18752
19231
  /** Insertion-ordered list of every control-measure id. */
18753
19232
  const CONTROL_MEASURE_IDS = Object.keys(DEFINITIONS);
18754
- /**
18755
- * Merges a definition's resolved `rule` object back onto its metadata,
18756
- * preserving ADR-0005's `getControlMeasureMetadata` shape. Definitions whose
18757
- * `drawRule` id has no implementation leave `rule` undefined.
18758
- */
18759
- function withRule(definition) {
18760
- return definition.rule ? {
19233
+ /** The shared label dimension materialized for capture-enabled definitions. */
19234
+ const LABEL_SIZE_PAIR = {
19235
+ id: "label",
19236
+ label: "Label size",
19237
+ pixels: "labelSizePixels",
19238
+ meters: "labelSize",
19239
+ defaultUnit: "px",
19240
+ defaultValue: 14,
19241
+ rendering: "text",
19242
+ captureOnDraw: true
19243
+ };
19244
+ /** Resolve definition-owned metadata and the shared label-capture capability. */
19245
+ function resolveMetadata(definition) {
19246
+ const sizePairs = [...definition.metadata.sizePairs ?? [], ...definition.metadata.capturesLabelSize ? [LABEL_SIZE_PAIR] : []];
19247
+ return {
18761
19248
  ...definition.metadata,
18762
- rule: definition.rule
18763
- } : definition.metadata;
19249
+ ...definition.rule ? { rule: definition.rule } : {},
19250
+ sizePairs
19251
+ };
18764
19252
  }
18765
- const CONTROL_MEASURE_METADATA = Object.fromEntries(Object.entries(DEFINITIONS).map(([id, definition]) => [id, withRule(definition)]));
19253
+ const CONTROL_MEASURE_METADATA = Object.fromEntries(Object.entries(DEFINITIONS).map(([id, definition]) => [id, resolveMetadata(definition)]));
18766
19254
  const CONTROL_MEASURE_METADATA_LIST = Object.values(CONTROL_MEASURE_METADATA);
18767
19255
  const CONTROL_MEASURE_METADATA_BY_VALUE = CONTROL_MEASURE_METADATA_LIST.reduce((acc, metadata) => {
18768
19256
  acc[metadata.value] = metadata;
@@ -19135,4 +19623,4 @@ function assertNever(value) {
19135
19623
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
19136
19624
  }
19137
19625
  //#endregion
19138
- export { DEFAULT_PHASE_LINE_OPTIONS as $, getMetersPerPixel as $n, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as $t, DEFAULT_SECURE_OPTIONS as A, staticPointDrawRule as An, DEFAULT_GENERIC_CIRCLE_OPTIONS as At, DEFAULT_FORTIFIED_LINE_OPTIONS as B, computeDefaultMidpointPerpendicularPoint as Bn, DEFAULT_BLOCK_ARROW_OPTIONS as Bt, DEFAULT_LANDING_ZONE_OPTIONS as C, line23DrawRule as Cn, RADAR_SEARCH_DOCTRINE_FILL_COLOR as Ct, DEFAULT_WITHDRAW_OPTIONS as D, attackByFireDrawRule as Dn, DEFAULT_GENERIC_RECTANGLE_OPTIONS as Dt, DEFAULT_WITHDRAW_UNDER_PRESSURE_OPTIONS as E, ambushDrawRule as En, DEFAULT_GENERIC_SECTOR_OPTIONS as Et, DEFAULT_PENETRATE_OPTIONS as F, disruptDrawRule as Fn, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Ft, DEFAULT_LIMIT_OF_ADVANCE_OPTIONS as G, createBaselineFrame as Gn, DEFAULT_ATTACK_BY_FIRE_OPTIONS as Gt, PROBABLE_LINE_OF_DEPLOYMENT_DASH as H, getMidpointPerpendicularSignedDistance as Hn, DEFAULT_CONTAIN_OPTIONS as Ht, DEFAULT_OCCUPY_OPTIONS as I, blockDrawRule as In, DEFAULT_GENERIC_C2_LINE_OPTIONS as It, DEFAULT_FORWARD_EDGE_OF_BATTLE_AREA_OPTIONS as J, haversineDistance as Jn, DEFAULT_ASSEMBLY_AREA_OPTIONS as Jt, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as K, calculateMetrics as Kn, DEFAULT_ANTITANK_WALL_OPTIONS as Kt, DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS as L, centerRadiusDrawRule as Ln, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as Lt, DEFAULT_RETIRE_OPTIONS as M, secureDrawRule as Mn, DEFAULT_CANALIZE_OPTIONS as Mt, DEFAULT_RELIEF_IN_PLACE_OPTIONS as N, penetrateDrawRule as Nn, DEFAULT_BYPASS_OPTIONS as Nt, DEFAULT_EVACUATE_OPTIONS as O, dynamicPointDrawRule as On, DEFAULT_GENERIC_POLYGON_OPTIONS as Ot, DEFAULT_REARWARD_PASSAGE_OF_LINES_OPTIONS as P, occupyDrawRule as Pn, DEFAULT_BREACH_OPTIONS as Pt, DEFAULT_BRIDGEHEAD_LINE_OPTIONS as Q, EPSILON as Qn, DEFAULT_AREA_DEFENSE_OPTIONS as Qt, DEFAULT_FRONTAL_ATTACK_OPTIONS as R, containDrawRule as Rn, DEFAULT_LIGHT_LINE_OPTIONS as Rt, DEFAULT_MAIN_ATTACK_OPTIONS as S, line24DrawRule as Sn, DEFAULT_RADAR_SEARCH_DOCTRINE_OPTIONS as St, DEFAULT_TURN_OPTIONS as T, line1DrawRule as Tn, DEFAULT_GENERIC_TEXT_OPTIONS as Tt, DEFAULT_LINE_OF_DEPARTURE_CONTACT_OPTIONS as U, pointOnMidpointPerpendicularAxis as Un, DEFAULT_BATTLE_POSITION_OPTIONS as Ut, DEFAULT_PROBABLE_LINE_OF_DEPLOYMENT_OPTIONS as V, createMidpointPerpendicularDrawRule as Vn, DEFAULT_BLOCK_OPTIONS as Vt, DEFAULT_LINE_OF_DEPARTURE_OPTIONS as W, snapToMidpointPerpendicular as Wn, DEFAULT_ATTACK_HELICOPTER_OPTIONS as Wt, DEFAULT_HOLDING_LINE_OPTIONS as X, sphericalBearing as Xn, DEFAULT_MOBILE_DEFENSE_OPTIONS as Xt, DEFAULT_RELEASE_LINE_OPTIONS as Y, project as Yn, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as Yt, DEFAULT_INFILTRATION_LANE_OPTIONS as Z, unproject as Zn, DEFAULT_ENGAGEMENT_AREA_OPTIONS as Zt, DEFAULT_PICKUP_ZONE_OPTIONS as _, axis1DrawRule as _n, DEFAULT_SUPPORTING_ATTACK_OPTIONS as _t, DEFINITIONS as a, resolveAmplifierPlacement as an, DEFAULT_DISRUPT_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as b, line27DrawRule as bn, DEFAULT_ISOLATE_OPTIONS as bt, getDefaultOptions as c, DEFAULT_LINE_CAP as cn, createFollowAndAssume as ct, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as d, DEFAULT_STROKE_DASH_CSS_PIXELS as dn, DEFAULT_DEMONSTRATE_OPTIONS as dt, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as en, roundToFixed as er, DEFAULT_FLOT_OPTIONS as et, DEFAULT_RETAIN_OPTIONS as f, DEFAULT_STROKE_WIDTH_CSS_PIXELS as fn, DEFAULT_DELAY_OPTIONS as ft, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as g, rectangleDrawRule as gn, DEFAULT_COUNTERATTACK_OPTIONS as gt, SECONDARY_DIRECTION_OF_FIRE_DASH as h, DEFAULT_AIRBORNE_ATTACK_OPTIONS as hn, DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, normalizeTextAmplifiers as in, DEFAULT_ENCIRCLEMENT_OPTIONS as it, DEFAULT_SCREEN_OPTIONS as j, point12DrawRule as jn, DEFAULT_CLASSIC_ARROW_OPTIONS as jt, DEFAULT_SEIZE_OPTIONS as k, sectorDrawRule as kn, DEFAULT_GENERIC_LINE_OPTIONS as kt, listControlMeasureMetadata as l, DEFAULT_LINE_JOIN as ln, DEFAULT_DISRUPT_MISSION_TASK_OPTIONS as lt, DEFAULT_SECONDARY_DIRECTION_OF_FIRE_OPTIONS as m, DEFAULT_AMBUSH_OPTIONS as mn, DEFAULT_TACTICAL_ARROW_OPTIONS as mt, resolveStyleHints as n, TEXT_AMPLIFIER_FIELDS as nn, DEFAULT_FIX_OPTIONS as nt, getControlMeasureMetadata as o, DEFAULT_LABEL_HEIGHT_CSS_PIXELS as on, DEFAULT_GUARD_OPTIONS as ot, DEFAULT_STRONG_POINT_OPTIONS as p, DEFAULT_SYMBOL_COLOR as pn, DEFAULT_COVER_OPTIONS as pt, DEFAULT_HANDOVER_LINE_OPTIONS as q, computeInitialWidthPoint as qn, DEFAULT_ANTITANK_DITCH_OPTIONS as qt, CONTROL_MEASURE_IDS as r, canonicalTextAmplifierKey as rn, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as rt, getControlMeasureMetadataByValue as s, DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS as sn, createFollowAndSupport as st, renderControlMeasure as t, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as tn, DEFAULT_FIX_MISSION_TASK_OPTIONS as tt, DEFAULT_TURNING_MOVEMENT_OPTIONS as u, DEFAULT_PORTRAYAL as un, DEFAULT_DISENGAGE_OPTIONS as ut, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as v, supportByFireDrawRule as vn, DEFAULT_CORDON_AND_SEARCH_OPTIONS as vt, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as w, turnDrawRule as wn, RADAR_SEARCH_DOCTRINE_STROKE_COLOR as wt, DEFAULT_MINEFIELD_OPTIONS as x, line26DrawRule as xn, DEFAULT_CLEAR_OPTIONS as xt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as y, polyline1DrawRule as yn, DEFAULT_CORDON_AND_KNOCK_OPTIONS as yt, DEFAULT_FORTIFIED_AREA_OPTIONS as z, area27DrawRule as zn, DEFAULT_BOUNDARY_OPTIONS as zt };
19626
+ export { DEFAULT_PHASE_LINE_OPTIONS as $, DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS as $n, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as $t, DEFAULT_SECURE_OPTIONS as A, secureDrawRule as An, DEFAULT_GENERIC_CIRCLE_OPTIONS as At, DEFAULT_FORTIFIED_LINE_OPTIONS as B, getMidpointPerpendicularSignedDistance as Bn, DEFAULT_BLOCK_ARROW_OPTIONS as Bt, DEFAULT_LANDING_ZONE_OPTIONS as C, line1DrawRule as Cn, RADAR_SEARCH_DOCTRINE_FILL_COLOR as Ct, DEFAULT_WITHDRAW_OPTIONS as D, sectorDrawRule as Dn, DEFAULT_GENERIC_RECTANGLE_OPTIONS as Dt, DEFAULT_WITHDRAW_UNDER_PRESSURE_OPTIONS as E, dynamicPointDrawRule as En, DEFAULT_GENERIC_SECTOR_OPTIONS as Et, DEFAULT_PENETRATE_OPTIONS as F, centerRadiusDrawRule as Fn, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Ft, DEFAULT_LIMIT_OF_ADVANCE_OPTIONS as G, project as Gn, DEFAULT_ATTACK_BY_FIRE_OPTIONS as Gt, PROBABLE_LINE_OF_DEPLOYMENT_DASH as H, snapToMidpointPerpendicular as Hn, DEFAULT_CONTAIN_OPTIONS as Ht, DEFAULT_OCCUPY_OPTIONS as I, containDrawRule as In, DEFAULT_GENERIC_C2_LINE_OPTIONS as It, DEFAULT_FORWARD_EDGE_OF_BATTLE_AREA_OPTIONS as J, EPSILON as Jn, DEFAULT_ASSEMBLY_AREA_OPTIONS as Jt, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as K, sphericalBearing as Kn, DEFAULT_ANTITANK_WALL_OPTIONS as Kt, DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS as L, area27DrawRule as Ln, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as Lt, DEFAULT_RETIRE_OPTIONS as M, occupyDrawRule as Mn, DEFAULT_CANALIZE_OPTIONS as Mt, DEFAULT_RELIEF_IN_PLACE_OPTIONS as N, disruptDrawRule as Nn, DEFAULT_BYPASS_OPTIONS as Nt, DEFAULT_EVACUATE_OPTIONS as O, staticPointDrawRule as On, DEFAULT_GENERIC_POLYGON_OPTIONS as Ot, DEFAULT_REARWARD_PASSAGE_OF_LINES_OPTIONS as P, blockDrawRule as Pn, DEFAULT_BREACH_OPTIONS as Pt, DEFAULT_BRIDGEHEAD_LINE_OPTIONS as Q, DEFAULT_LABEL_HEIGHT_CSS_PIXELS as Qn, DEFAULT_AREA_DEFENSE_OPTIONS as Qt, DEFAULT_FRONTAL_ATTACK_OPTIONS as R, computeDefaultMidpointPerpendicularPoint as Rn, DEFAULT_LIGHT_LINE_OPTIONS as Rt, DEFAULT_MAIN_ATTACK_OPTIONS as S, turnDrawRule as Sn, DEFAULT_RADAR_SEARCH_DOCTRINE_OPTIONS as St, DEFAULT_TURN_OPTIONS as T, attackByFireDrawRule as Tn, DEFAULT_GENERIC_TEXT_OPTIONS as Tt, DEFAULT_LINE_OF_DEPARTURE_CONTACT_OPTIONS as U, createBaselineFrame as Un, DEFAULT_BATTLE_POSITION_OPTIONS as Ut, DEFAULT_PROBABLE_LINE_OF_DEPLOYMENT_OPTIONS as V, pointOnMidpointPerpendicularAxis as Vn, DEFAULT_BLOCK_OPTIONS as Vt, DEFAULT_LINE_OF_DEPARTURE_OPTIONS as W, haversineDistance as Wn, DEFAULT_ATTACK_HELICOPTER_OPTIONS as Wt, DEFAULT_HOLDING_LINE_OPTIONS as X, isFiniteNumber as Xn, DEFAULT_MOBILE_DEFENSE_OPTIONS as Xt, DEFAULT_RELEASE_LINE_OPTIONS as Y, getMetersPerPixel as Yn, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as Yt, DEFAULT_INFILTRATION_LANE_OPTIONS as Z, roundToFixed as Zn, DEFAULT_ENGAGEMENT_AREA_OPTIONS as Zt, DEFAULT_PICKUP_ZONE_OPTIONS as _, polyline1DrawRule as _n, DEFAULT_SUPPORTING_ATTACK_OPTIONS as _t, DEFINITIONS as a, resolveAmplifierPlacement as an, DEFAULT_SYMBOL_COLOR as ar, DEFAULT_DISRUPT_OPTIONS as at, DEFAULT_OBSTACLE_BYPASS_DIFFICULT_OPTIONS as b, line24DrawRule as bn, DEFAULT_ISOLATE_OPTIONS as bt, getDefaultOptions as c, DEFAULT_DESTROY_OPTIONS as cn, createFollowAndAssume as ct, DEFAULT_SUPPORT_BY_FIRE_OPTIONS as d, DEFAULT_DEFEAT_OPTIONS as dn, DEFAULT_DEMONSTRATE_OPTIONS as dt, DEFAULT_DIRECTION_OF_MAIN_ATTACK_OPTIONS as en, DEFAULT_LINE_CAP as er, DEFAULT_FLOT_OPTIONS as et, DEFAULT_RETAIN_OPTIONS as f, rectangleDrawRule as fn, DEFAULT_DELAY_OPTIONS as ft, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as g, supportByFireDrawRule as gn, DEFAULT_COUNTERATTACK_OPTIONS as gt, SECONDARY_DIRECTION_OF_FIRE_DASH as h, computeInitialWidthPoint as hn, DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, normalizeTextAmplifiers as in, DEFAULT_STROKE_WIDTH_CSS_PIXELS as ir, DEFAULT_ENCIRCLEMENT_OPTIONS as it, DEFAULT_SCREEN_OPTIONS as j, penetrateDrawRule as jn, DEFAULT_CLASSIC_ARROW_OPTIONS as jt, DEFAULT_SEIZE_OPTIONS as k, point12DrawRule as kn, DEFAULT_GENERIC_LINE_OPTIONS as kt, listControlMeasureMetadata as l, DEFAULT_NEUTRALIZE_OPTIONS as ln, DEFAULT_DISRUPT_MISSION_TASK_OPTIONS as lt, DEFAULT_SECONDARY_DIRECTION_OF_FIRE_OPTIONS as m, calculateMetrics as mn, DEFAULT_TACTICAL_ARROW_OPTIONS as mt, resolveStyleHints as n, TEXT_AMPLIFIER_FIELDS as nn, DEFAULT_PORTRAYAL as nr, DEFAULT_FIX_OPTIONS as nt, getControlMeasureMetadata as o, DEFAULT_AMBUSH_OPTIONS as on, DEFAULT_GUARD_OPTIONS as ot, DEFAULT_STRONG_POINT_OPTIONS as p, axis1DrawRule as pn, DEFAULT_COVER_OPTIONS as pt, DEFAULT_HANDOVER_LINE_OPTIONS as q, unproject as qn, DEFAULT_ANTITANK_DITCH_OPTIONS as qt, CONTROL_MEASURE_IDS as r, canonicalTextAmplifierKey as rn, DEFAULT_STROKE_DASH_CSS_PIXELS as rr, DEFAULT_FINAL_PROTECTIVE_FIRE_OPTIONS as rt, getControlMeasureMetadataByValue as s, DEFAULT_AIRBORNE_ATTACK_OPTIONS as sn, createFollowAndSupport as st, renderControlMeasure as t, DEFAULT_DIRECTION_OF_ATTACK_AVIATION_OPTIONS as tn, DEFAULT_LINE_JOIN as tr, DEFAULT_FIX_MISSION_TASK_OPTIONS as tt, DEFAULT_TURNING_MOVEMENT_OPTIONS as u, DEFAULT_SUPPRESS_OPTIONS as un, DEFAULT_DISENGAGE_OPTIONS as ut, DEFAULT_OBSTACLE_BYPASS_IMPOSSIBLE_OPTIONS as v, line27DrawRule as vn, DEFAULT_CORDON_AND_SEARCH_OPTIONS as vt, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as w, ambushDrawRule as wn, RADAR_SEARCH_DOCTRINE_STROKE_COLOR as wt, DEFAULT_MINEFIELD_OPTIONS as x, line23DrawRule as xn, DEFAULT_CLEAR_OPTIONS as xt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as y, line26DrawRule as yn, DEFAULT_CORDON_AND_KNOCK_OPTIONS as yt, DEFAULT_FORTIFIED_AREA_OPTIONS as z, createMidpointPerpendicularDrawRule as zn, DEFAULT_BOUNDARY_OPTIONS as zt };