@orbat-mapper/control-measures 0.19.0 → 0.20.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,13 @@
1
+ //#region src/define.ts
2
+ /**
3
+ * Identity helper that infers a definition's literal `Id` and precise generator
4
+ * type `G`, so the registry's `typeof DEFINITIONS` carries each measure's exact
5
+ * options type. Internal authoring API — not part of the published surface.
6
+ */
7
+ function defineControlMeasure(definition) {
8
+ return definition;
9
+ }
10
+ //#endregion
1
11
  //#region src/internal/graphics-utils.ts
2
12
  /**
3
13
  * Small value used to avoid division by zero and floating-point precision issues.
@@ -275,240 +285,6 @@ function lineIntersection(p1, p2, p3, p4) {
275
285
  return [x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)];
276
286
  }
277
287
  //#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) {
293
- 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
- }];
306
- },
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 };
317
- }
318
- };
319
- }
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
- });
344
- }
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;
353
- }
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) };
373
- }
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;
399
- }
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
288
  //#region src/draw-rules/baseline-frame.ts
513
289
  function createBaselineFrame(p1, p2, options = {}) {
514
290
  return createProjectedBaselineFrame(project(p1[0], p1[1]), project(p2[0], p2[1]), options);
@@ -1558,18 +1334,242 @@ const supportByFireDrawRule = {
1558
1334
  }
1559
1335
  };
1560
1336
  //#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
- }
1337
+ //#region src/attack-utils.ts
1338
+ const DEFAULT_SHAFT_WIDTH_RATIO$1 = .6;
1339
+ const DEFAULT_REAR_WIDTH_RATIO = DEFAULT_SHAFT_WIDTH_RATIO$1;
1340
+ const SHAFT_MIN_RATIO = .1;
1341
+ const SHAFT_MAX_RATIO = .9;
1342
+ const REAR_WIDTH_OPTION_HANDLE_ID = "rear-width";
1343
+ const SHAFT_WIDTH_OPTION_HANDLE_ID = "shaft-width";
1344
+ /**
1345
+ * Creates the definition-owned rear-/shaft-width handle pair shared by every
1346
+ * variable-width arrow body. Callers supply only how their own geometry maps
1347
+ * onto {@link WidthHandleGeometry}; placement (the two ends of the right edge)
1348
+ * and the drag math (perpendicular distance from the dragged point to the
1349
+ * matching centerline segment, over the reference half-width) live here once.
1350
+ */
1351
+ function createWidthOptionHandles(config) {
1352
+ return {
1353
+ get(controlPoints, options) {
1354
+ const geometry = config.geometry(controlPoints, options);
1355
+ const rearRight = geometry?.rightEdge[0];
1356
+ const neckRight = geometry?.rightEdge.at(-1);
1357
+ if (!rearRight || !neckRight) return [];
1358
+ return [{
1359
+ id: REAR_WIDTH_OPTION_HANDLE_ID,
1360
+ position: unproject(rearRight[0], rearRight[1])
1361
+ }, {
1362
+ id: SHAFT_WIDTH_OPTION_HANDLE_ID,
1363
+ position: unproject(neckRight[0], neckRight[1])
1364
+ }];
1365
+ },
1366
+ drag({ controlPoints, options, handleId, position }) {
1367
+ const rear = handleId === REAR_WIDTH_OPTION_HANDLE_ID;
1368
+ if (!rear && !(handleId === "shaft-width")) return void 0;
1369
+ const geometry = config.geometry(controlPoints, options);
1370
+ if (!geometry || geometry.referenceHalfWidth < 1e-6) return void 0;
1371
+ const from = rear ? geometry.spine[0] : geometry.spine.at(-2);
1372
+ const to = rear ? geometry.spine[1] : geometry.spine.at(-1);
1373
+ if (!from || !to) return void 0;
1374
+ const ratio = clamp(pointToInfiniteLineDistance(project(position[0], position[1]), from, to) / geometry.referenceHalfWidth, config.minRatio, rear ? config.maxRearRatio : config.maxShaftRatio);
1375
+ return rear ? { rearWidthRatio: ratio } : { shaftWidthRatio: ratio };
1376
+ }
1377
+ };
1378
+ }
1379
+ /**
1380
+ * The width handles for attack bodies built by {@link processAttackGeometry},
1381
+ * whose ratios are fractions of the arrowhead half-width. A coordinate resolver
1382
+ * lets derivative measures (Counterattack by Fire) map their authored Axis1
1383
+ * points to the body coordinates first.
1384
+ */
1385
+ function createVariableWidthAttackOptionHandles(resolveCoordinates = (points) => points) {
1386
+ return createWidthOptionHandles({
1387
+ geometry(controlPoints, options) {
1388
+ const coordinates = resolveCoordinates(controlPoints);
1389
+ if (!coordinates) return null;
1390
+ const { geometry } = processAttackGeometry(coordinates, options);
1391
+ const outerLeft = geometry?.headRing[0];
1392
+ if (!geometry || !outerLeft) return null;
1393
+ return {
1394
+ spine: geometry.shaftCenterline,
1395
+ rightEdge: geometry.shaftRight,
1396
+ referenceHalfWidth: vecMag(vecSub(outerLeft, geometry.ptBase))
1397
+ };
1398
+ },
1399
+ minRatio: SHAFT_MIN_RATIO,
1400
+ maxRearRatio: 3,
1401
+ maxShaftRatio: SHAFT_MAX_RATIO
1402
+ });
1403
+ }
1404
+ const variableWidthAttackOptionHandles = createVariableWidthAttackOptionHandles();
1405
+ /**
1406
+ * The single home of the "rear inherits shaft" rule: an omitted `rearWidthRatio`
1407
+ * leaves the body parallel by matching the effective shaft ratio. Every
1408
+ * variable-width body resolves the option through here so the rule cannot drift.
1409
+ */
1410
+ function resolveRearWidthRatio(options, shaftRatio) {
1411
+ return options.rearWidthRatio ?? shaftRatio;
1412
+ }
1413
+ /**
1414
+ * Processes input coordinates and options to generate the core symbol geometry.
1415
+ * This handles validation, default values, projection, and geometry calculation.
1416
+ */
1417
+ function processAttackGeometry(coordinates, options = {}) {
1418
+ const shaftRatio = clamp(options.shaftWidthRatio ?? .6, SHAFT_MIN_RATIO, SHAFT_MAX_RATIO);
1419
+ const smooth = options.smooth ?? false;
1420
+ const smoothResolution = options.smoothResolution ?? 5;
1421
+ const rearRatio = clamp(resolveRearWidthRatio(options, shaftRatio), SHAFT_MIN_RATIO, 3);
1422
+ const points = coordinates.map((c) => project(c[0], c[1]));
1423
+ const numPoints = points.length;
1424
+ const ptTip = points[0];
1425
+ const ptWidth = points[numPoints - 1];
1426
+ const spinePoints = [];
1427
+ for (let i = numPoints - 2; i >= 1; i--) {
1428
+ const p = points[i];
1429
+ if (p) spinePoints.push(p);
1430
+ }
1431
+ return { geometry: calculateSymbolGeometry(ptTip, ptWidth, spinePoints, shaftRatio, rearRatio, smooth, smoothResolution) };
1432
+ }
1433
+ function calculateSymbolGeometry(ptTip, ptWidth, spine, shaftRatio, rearWidthRatio, smooth, smoothResolution) {
1434
+ const initialNeck = spine[spine.length - 1];
1435
+ if (!initialNeck) return null;
1436
+ const initialTipDir = vecNorm(vecSub(ptTip, initialNeck));
1437
+ if (vecMag(initialTipDir) < 1e-6) return null;
1438
+ const headHalfWidth = pointToInfiniteLineDistance(ptWidth, initialNeck, ptTip);
1439
+ const vecTipToWidth = vecSub(ptTip, ptWidth);
1440
+ const requestedHeadLength = Math.max(EPSILON, vecDot(vecTipToWidth, initialTipDir));
1441
+ const path = [...spine, ptTip];
1442
+ let totalArcLength = 0;
1443
+ for (let i = 1; i < path.length; i++) totalArcLength += vecMag(vecSub(path[i], path[i - 1]));
1444
+ const minimumShaftLength = Math.max(EPSILON, totalArcLength * .01);
1445
+ let distanceRemaining = clamp(requestedHeadLength, EPSILON, Math.max(EPSILON, totalArcLength - minimumShaftLength));
1446
+ let ptBase = ptTip;
1447
+ let remainingSpine = spine;
1448
+ for (let i = path.length - 1; i > 0; i--) {
1449
+ const segmentEnd = path[i];
1450
+ const segmentStart = path[i - 1];
1451
+ const segment = vecSub(segmentStart, segmentEnd);
1452
+ const segmentLength = vecMag(segment);
1453
+ if (segmentLength < 1e-6) continue;
1454
+ if (distanceRemaining < segmentLength) {
1455
+ ptBase = vecAdd(segmentEnd, vecScale(segment, distanceRemaining / segmentLength));
1456
+ remainingSpine = path.slice(0, i);
1457
+ break;
1458
+ }
1459
+ distanceRemaining -= segmentLength;
1460
+ }
1461
+ const ptNeck = remainingSpine[remainingSpine.length - 1];
1462
+ if (!ptNeck) return null;
1463
+ const tipDir = vecNorm(vecSub(ptTip, ptNeck));
1464
+ if (vecMag(tipDir) < 1e-6) return null;
1465
+ const headLength = vecMag(vecSub(ptTip, ptBase));
1466
+ const shaftHalfWidth = headHalfWidth * shaftRatio;
1467
+ const perpDir = [-tipDir[1], tipDir[0]];
1468
+ const outerLeft = vecAdd(ptBase, vecScale(perpDir, headHalfWidth));
1469
+ const outerRight = vecAdd(ptBase, vecScale(perpDir, -headHalfWidth));
1470
+ const innerLeft = vecAdd(ptBase, vecScale(perpDir, shaftHalfWidth));
1471
+ const innerRight = vecAdd(ptBase, vecScale(perpDir, -shaftHalfWidth));
1472
+ const notchDepth = headLength * shaftRatio;
1473
+ const safeNotchDepth = Math.min(notchDepth, headLength - EPSILON);
1474
+ const innerTip = vecAdd(ptBase, vecScale(tipDir, safeNotchDepth));
1475
+ const shaftEndCenter = ptBase;
1476
+ const headRing = [
1477
+ outerLeft,
1478
+ ptTip,
1479
+ outerRight,
1480
+ innerRight,
1481
+ innerTip,
1482
+ innerLeft,
1483
+ outerLeft
1484
+ ];
1485
+ const fullSpine = [...remainingSpine, shaftEndCenter];
1486
+ const { left: shaftLeft, right: shaftRight } = offsetPolylineSides(fullSpine, interpolatePolylineValues(fullSpine, headHalfWidth * rearWidthRatio, shaftHalfWidth), smooth, smoothResolution);
1487
+ return {
1488
+ shaftCenterline: fullSpine,
1489
+ shaftLeft,
1490
+ shaftRight,
1491
+ headRing,
1492
+ ptTip,
1493
+ ptNeck,
1494
+ ptBase
1495
+ };
1496
+ }
1497
+ /**
1498
+ * Calculates the relative metrics (longitudinal and lateral) of the width point
1499
+ * relative to the Tip-Neck axis.
1500
+ *
1501
+ * @param pts - Array of positions [Tip, Neck, ..., WidthPoint]
1502
+ * @returns { longitudinal: number, lateral: number } | null
1503
+ */
1504
+ function calculateMetrics(pts) {
1505
+ const tip = pts[0];
1506
+ const neck = pts[1];
1507
+ const widthPt = pts[pts.length - 1];
1508
+ if (!tip || !neck || !widthPt) return null;
1509
+ const tipM = project(tip[0], tip[1]);
1510
+ const neckM = project(neck[0], neck[1]);
1511
+ const widthM = project(widthPt[0], widthPt[1]);
1512
+ const tx = tipM[0];
1513
+ const ty = tipM[1];
1514
+ const nx = neckM[0];
1515
+ const ny = neckM[1];
1516
+ const wx = widthM[0];
1517
+ const wy = widthM[1];
1518
+ const dx = tx - nx;
1519
+ const dy = ty - ny;
1520
+ const len = Math.sqrt(dx * dx + dy * dy);
1521
+ if (len < 1e-6) return null;
1522
+ const ax = dx / len;
1523
+ const ay = dy / len;
1524
+ const px = -ay;
1525
+ const py = ax;
1526
+ const vx = wx - tx;
1527
+ const vy = wy - ty;
1528
+ return {
1529
+ longitudinal: vx * ax + vy * ay,
1530
+ lateral: vx * px + vy * py
1531
+ };
1532
+ }
1533
+ /**
1534
+ * Computes an initial Width Point based on the Tip and Neck positions.
1535
+ * It places the point perpendicular to the neck at a default distance relative to the length.
1536
+ *
1537
+ * @param tip - Tip position (Lon/Lat)
1538
+ * @param neck - Neck position (Lon/Lat)
1539
+ * @returns Width Point position (Lon/Lat)
1540
+ */
1541
+ function computeInitialWidthPoint(tip, neck) {
1542
+ const tipM = project(tip[0], tip[1]);
1543
+ const neckM = project(neck[0], neck[1]);
1544
+ const tx = tipM[0];
1545
+ const ty = tipM[1];
1546
+ const nx = neckM[0];
1547
+ const ny = neckM[1];
1548
+ const dx = tx - nx;
1549
+ const dy = ty - ny;
1550
+ const len = Math.sqrt(dx * dx + dy * dy);
1551
+ if (len < 1e-6) return unproject(nx + 100, ny);
1552
+ const ax = dx / len;
1553
+ const ay = dy / len;
1554
+ const widthRatio = .35;
1555
+ const recessionRatio = .25;
1556
+ const px = -ay;
1557
+ const py = ax;
1558
+ return unproject(tx - dx * recessionRatio + len * widthRatio * px, ty - dy * recessionRatio + len * widthRatio * py);
1559
+ }
1560
+ //#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
1573
  return clonePositions(points);
1574
1574
  }
1575
1575
  function projectWidthIntoFrame(next, longitudinal, lateral) {
@@ -1772,1723 +1772,2246 @@ const line25DrawRule = {
1772
1772
  transform: (event) => clonePositions(event.next.slice(0, 2))
1773
1773
  };
1774
1774
  //#endregion
1775
- //#region src/internal/echelon.ts
1775
+ //#region src/style.ts
1776
1776
  /**
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).
1779
- *
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 `++`.
1777
+ * Per-feature style hint pinning a filled part to a solid interior. Attach to
1778
+ * a generator's intrinsic doctrinal accents — arrowheads, teeth, echelon
1779
+ * glyphs, barbs, blades — so a patterned (`hatch`, `dots`, …) fill set at the graphicsStyle
1780
+ * or measure layer never bleeds into a small silhouette and ruins its
1781
+ * legibility. The renderer only reads style hints, so one shared instance is
1782
+ * safe to reuse across every accent feature.
1784
1783
  */
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"
1784
+ const SOLID_ACCENT_FILL = { fillPattern: "solid" };
1785
+ /**
1786
+ * Ultimate fallback for the symbol color when no `color` or per-channel
1787
+ * override is supplied at any layer. Keeps a zero-config render monocolor
1788
+ * black and guarantees filled parts still render filled. See ADR-0011.
1789
+ */
1790
+ const DEFAULT_SYMBOL_COLOR = "#000000";
1791
+ //#endregion
1792
+ //#region src/portrayal.ts
1793
+ const DEFAULT_STROKE_WIDTH_CSS_PIXELS = 2;
1794
+ const DEFAULT_STROKE_DASH_CSS_PIXELS = Object.freeze([]);
1795
+ const DEFAULT_LINE_CAP = "round";
1796
+ const DEFAULT_LINE_JOIN = "round";
1797
+ const DEFAULT_LABEL_HEIGHT_CSS_PIXELS = 14;
1798
+ const DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS = {
1799
+ min: 8,
1800
+ max: 24
1877
1801
  };
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
- }))];
1802
+ /** Shared absent-value portrayal defaults; render output remains sparse. */
1803
+ const DEFAULT_PORTRAYAL = Object.freeze({
1804
+ symbolColor: DEFAULT_SYMBOL_COLOR,
1805
+ strokeWidthCssPixels: 2,
1806
+ strokeDashCssPixels: DEFAULT_STROKE_DASH_CSS_PIXELS,
1807
+ lineCap: DEFAULT_LINE_CAP,
1808
+ lineJoin: DEFAULT_LINE_JOIN,
1809
+ labelHeightCssPixels: 14
1810
+ });
1811
+ //#endregion
1812
+ //#region src/internal/angle-utils.ts
1886
1813
  /**
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.
1814
+ * Wraps an angle (radians) into the half-open range (-π, π].
1890
1815
  */
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);
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;
1895
1821
  }
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);
1822
+ /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
1823
+ function normalizeDegrees(degrees) {
1824
+ return (degrees % 360 + 360) % 360;
1901
1825
  }
1902
1826
  /**
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.
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).
1908
1829
  */
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
- }
1991
- }
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;
1992
1835
  }
1993
1836
  //#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"
2037
- },
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
2048
- }
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
2080
- }
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"
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
1863
+ };
1864
+ }
1865
+ /**
1866
+ * Clamps and rounds a smooth-resolution option (samples per segment) onto
1867
+ * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
1868
+ * for non-finite input. The default varies per measure, so it's passed in.
1869
+ */
1870
+ function normalizeSmoothResolution$2(value, fallback) {
1871
+ if (!Number.isFinite(value)) return fallback;
1872
+ return Math.min(64, Math.max(2, Math.round(value)));
1873
+ }
1874
+ /**
1875
+ * Shared param descriptors for {@link SmoothLineOptions}, modeled on
1876
+ * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
1877
+ * their own params should spread these at the top of the array.
1878
+ */
1879
+ const SMOOTH_LINE_PARAMS = [{
1880
+ key: "smooth",
1881
+ label: "Smooth",
1882
+ description: "Round the line's corners by curving the path through the control points.",
1883
+ type: "boolean"
2091
1884
  }, {
2092
- key: "toothSize",
2093
- label: "Tooth size",
2094
- description: "Size of the fortified-area teeth, in meters",
1885
+ key: "smoothResolution",
1886
+ presentationTier: "advanced",
1887
+ label: "Smooth resolution",
1888
+ description: "Number of samples per segment when smooth mode is enabled.",
2095
1889
  type: "number",
2096
- min: 1,
2097
- max: 500,
1890
+ min: 2,
1891
+ max: 64,
2098
1892
  step: 1,
2099
- unit: "m"
1893
+ visibleWhen: (opts) => Boolean(opts.smooth)
2100
1894
  }];
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
1895
+ /**
1896
+ * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
1897
+ * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
1898
+ * is set, else returns `verts` unchanged.
1899
+ */
1900
+ function smoothLineVerts(verts, options, defaultResolution) {
1901
+ return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
1902
+ }
1903
+ /**
1904
+ * Resamples a path into equal arc-length segments.
1905
+ *
1906
+ * This is useful for a repeated motif (such as a fortified tooth or FLOT
1907
+ * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
1908
+ * parameter, not distance, so using them directly as motif boundaries causes
1909
+ * the motifs to bunch up where the curve's samples are closer together.
1910
+ */
1911
+ function evenlySpacePath(points, targetSegmentLength) {
1912
+ if (points.length < 2) return points;
1913
+ const segmentLengths = [];
1914
+ let totalLength = 0;
1915
+ for (let i = 0; i < points.length - 1; i++) {
1916
+ const length = vecMag(vecSub(points[i + 1], points[i]));
1917
+ segmentLengths.push(length);
1918
+ totalLength += length;
2138
1919
  }
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
1920
+ if (totalLength < 1e-6) return [points[0]];
1921
+ const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
1922
+ const segmentLength = totalLength / segmentCount;
1923
+ const out = [points[0]];
1924
+ let sourceIndex = 0;
1925
+ let sourceStart = points[0];
1926
+ let consumed = 0;
1927
+ for (let segment = 1; segment < segmentCount; segment++) {
1928
+ const target = segment * segmentLength;
1929
+ while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
1930
+ consumed += segmentLengths[sourceIndex];
1931
+ sourceIndex++;
1932
+ sourceStart = points[sourceIndex];
1933
+ }
1934
+ const length = segmentLengths[sourceIndex];
1935
+ if (length < 1e-6) continue;
1936
+ const t = (target - consumed) / length;
1937
+ const sourceEnd = points[sourceIndex + 1];
1938
+ out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
2212
1939
  }
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
- };
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
- //#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
- };
1940
+ out.push(points[points.length - 1]);
1941
+ return out;
1942
+ }
1943
+ /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
1944
+ function catmullRomAt(p0, p1, p2, p3, t) {
1945
+ const t2 = t * t;
1946
+ const t3 = t2 * t;
1947
+ return [.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), .5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3)];
1948
+ }
2304
1949
  /**
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>.
1950
+ * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
1951
+ * segment. Endpoints are duplicated so the curve passes through them, so the
1952
+ * first and last points are preserved exactly. With `samples <= 1` (or fewer
1953
+ * than 3 points) it returns the input unchanged.
2313
1954
  */
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
- };
1955
+ function catmullRom(points, samples) {
1956
+ if (points.length < 3 || samples <= 1) return points;
1957
+ const pts = [
1958
+ points[0],
1959
+ ...points,
1960
+ points[points.length - 1]
1961
+ ];
1962
+ const out = [points[0]];
1963
+ for (let i = 1; i < pts.length - 2; i++) {
1964
+ const p0 = pts[i - 1];
1965
+ const p1 = pts[i];
1966
+ const p2 = pts[i + 1];
1967
+ const p3 = pts[i + 2];
1968
+ for (let s = 1; s <= samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
1969
+ }
1970
+ return out;
2341
1971
  }
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
1972
  /**
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.
1973
+ * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
1974
+ * wrapping the control points around the seam. Returns a ring whose first and
1975
+ * last positions coincide (explicit closure). With `samples <= 1` (or fewer
1976
+ * than 3 points) it returns the input unchanged.
2357
1977
  */
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;
1978
+ function closedCatmullRom(points, samples) {
1979
+ const n = points.length;
1980
+ if (n < 3 || samples <= 1) return points;
1981
+ const out = [];
1982
+ for (let i = 0; i < n; i++) {
1983
+ const p0 = points[(i - 1 + n) % n];
1984
+ const p1 = points[i];
1985
+ const p2 = points[(i + 1) % n];
1986
+ const p3 = points[(i + 2) % n];
1987
+ for (let s = 0; s < samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
1988
+ }
1989
+ out.push([...out[0]]);
1990
+ return out;
2363
1991
  }
1992
+ //#endregion
1993
+ //#region src/internal/line-labels.ts
2364
1994
  /**
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).
1995
+ * Stored label rotation for text running along projected direction `dir`.
1996
+ * Adapters render feature rotation with their `Math.PI - rotation` convention
1997
+ * (and Y-up projected space maps to Y-down screen as `-φ`), so we derive the
1998
+ * on-screen angle, keep its baseline left-to-right, then convert back. Sign-
1999
+ * independent: `labelRotationAlong(dir)` and `labelRotationAlong(vecScale(dir,
2000
+ * -1))` produce the same rotation, since the flip only depends on `dir`'s
2001
+ * quadrant, not the labeling caller's notion of "forward".
2370
2002
  */
2371
- function asThreePoints(points) {
2372
- return [
2373
- points[0],
2374
- points[1],
2375
- points[2]
2376
- ];
2003
+ function labelRotationAlong(dir) {
2004
+ const rendered = -Math.atan2(dir[1], dir[0]);
2005
+ return normalizeRadians(Math.PI - keepTextLeftToRight(rendered));
2377
2006
  }
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
2463
- }
2464
- ]
2465
- };
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]];
2469
- };
2470
- /**
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
2477
- */
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
- }
2007
+ /** Resolves two echelon-unit rows from a caller-selected set of amplifiers. */
2008
+ function unitLabelsFromAmplifiers(amplifiers, fields) {
2009
+ return [{
2010
+ side: 1,
2011
+ designator: amplifiers[fields.unit1.designator] ?? "",
2012
+ country: amplifiers[fields.unit1.country] ?? ""
2013
+ }, {
2014
+ side: -1,
2015
+ designator: amplifiers[fields.unit2.designator] ?? "",
2016
+ country: amplifiers[fields.unit2.country] ?? ""
2017
+ }];
2018
+ }
2019
+ /** Text size shared by an echelon glyph and its associated unit labels. */
2020
+ function echelonUnitLabelSize(echelonHeight, echelonSizePixels, metersPerPixel) {
2021
+ const screenAnchored = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0;
2526
2022
  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
- }]
2023
+ properties: screenAnchored ? { textSizePixels: echelonSizePixels } : { textSizeMeters: echelonHeight },
2024
+ screenAnchored
2536
2025
  };
2537
2026
  }
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
2027
  /**
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.
2028
+ * Emits a single combined `T (AS)` label per unit side (APP-6/MIL-STD-2525):
2029
+ * designator and country code share one text row rather than stacking.
2552
2030
  */
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
- });
2031
+ function pushUnitLabels(out, frame, units) {
2032
+ const { center, perp, offset, rotation, labelSize } = frame;
2033
+ for (const { side, designator, country, textAnchor, amplifierField } of units) {
2034
+ let text;
2035
+ if (designator.length > 0 && country.length > 0) text = `${designator} (${country})`;
2036
+ else if (designator.length > 0) text = designator;
2037
+ else if (country.length > 0) text = `(${country})`;
2038
+ else continue;
2039
+ pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor, amplifierField);
2576
2040
  }
2577
2041
  }
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
2042
  /**
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).
2043
+ * Default clearance between an echelon glyph and its unit labels, as a ratio of
2044
+ * the glyph height — the value Boundary declares as its `labelPadding` default,
2045
+ * shared so other echelon-carrying lines land on the same spacing.
2598
2046
  */
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));
2047
+ const ECHELON_UNIT_LABEL_PADDING = .4;
2048
+ /**
2049
+ * Emits Boundary-style unit labels around echelon groups. Along-line groups
2050
+ * straddle the line; standard-orientation groups on north/south segments form
2051
+ * one row crossing it, anchored outward from the glyph.
2052
+ */
2053
+ function pushEchelonUnitLabels(out, groups, units, echelonHeight, labelPadding, labelSize) {
2054
+ for (const { center, along, perp, rotateGroup, readingAxis, glyphWidth } of groups) {
2055
+ if (rotateGroup) {
2056
+ const sign = vecDot(perp, readingAxis) >= 0 ? 1 : -1;
2057
+ const clearance = glyphWidth / 2 + Math.max(0, labelPadding) * echelonHeight;
2058
+ pushUnitLabels(out, {
2059
+ center,
2060
+ perp: vecScale(readingAxis, sign),
2061
+ offset: clearance,
2062
+ rotation: labelRotationAlong(readingAxis),
2063
+ labelSize
2064
+ }, units.map((unit) => ({
2065
+ ...unit,
2066
+ textAnchor: unit.side === sign ? "start" : "end"
2067
+ })));
2068
+ continue;
2636
2069
  }
2637
- appendUnique(buffer, unproject(b[0], b[1]));
2638
- dist = segEnd;
2070
+ pushUnitLabels(out, {
2071
+ center,
2072
+ perp,
2073
+ offset: (.45 + Math.max(0, labelPadding)) * echelonHeight,
2074
+ rotation: labelRotationAlong(along),
2075
+ labelSize
2076
+ }, units);
2639
2077
  }
2640
- flush();
2641
- return parts;
2642
2078
  }
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
2079
  /**
2683
- * Wraps an angle (radians) into the half-open range (-π, π].
2080
+ * Walks `verts` (projected meters) into non-degenerate {@link LineSegment}s,
2081
+ * skipping coincident vertices (ADR-0014, silent) the same way Boundary's
2082
+ * gap-carving does. Empty or fully-degenerate input yields `totalLength: 0`
2083
+ * and no segments.
2684
2084
  */
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;
2085
+ function polylineSegments(verts) {
2086
+ const segments = [];
2087
+ let totalLength = 0;
2088
+ for (let i = 0; i < verts.length - 1; i++) {
2089
+ const a = verts[i];
2090
+ const seg = vecSub(verts[i + 1], a);
2091
+ const length = vecMag(seg);
2092
+ if (length < 1e-6) continue;
2093
+ const along = vecNorm(seg);
2094
+ segments.push({
2095
+ start: a,
2096
+ along,
2097
+ perp: [-along[1], along[0]],
2098
+ length
2099
+ });
2100
+ totalLength += length;
2101
+ }
2102
+ return {
2103
+ segments,
2104
+ totalLength
2105
+ };
2690
2106
  }
2691
- /** Wraps a degree value into the canonical clockwise `[0, 360)` range. */
2692
- function normalizeDegrees(degrees) {
2693
- return (degrees % 360 + 360) % 360;
2107
+ /** Resolves an along-line position onto the valid start-to-end range. */
2108
+ function clampLinePosition(value, fallback) {
2109
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2694
2110
  }
2695
2111
  /**
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).
2112
+ * Resolves a label/group repetition count to a positive whole number. Takes
2113
+ * `unknown` because the params panel's `visibleWhen` callbacks receive loosely
2114
+ * typed options; non-finite input falls back to a single group.
2698
2115
  */
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;
2116
+ function resolveLabelRepetitions(value) {
2117
+ const numeric = Number(value ?? 1);
2118
+ return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
2704
2119
  }
2705
- //#endregion
2706
- //#region src/internal/sketch.ts
2707
2120
  /**
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.
2121
+ * Resolves the point at `fraction` (clamped to `[0, 1]`) of `totalLength`
2122
+ * along `segments`, walking cumulative arc length exactly like Boundary's
2123
+ * label-group placement: the segment containing the target distance absorbs
2124
+ * any floating-point overshoot, and the last segment absorbs a fraction of 1.
2125
+ * Returns `undefined` for an empty or degenerate (`totalLength < EPSILON`)
2126
+ * polyline.
2711
2127
  */
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]];
2128
+ function pointAlongPolyline(segments, totalLength, fraction) {
2129
+ if (segments.length === 0 || totalLength < 1e-6) return void 0;
2130
+ const target = Math.min(1, Math.max(0, fraction)) * totalLength;
2131
+ let acc = 0;
2132
+ let seg = segments[segments.length - 1];
2133
+ for (const candidate of segments) {
2134
+ if (target <= acc + candidate.length) {
2135
+ seg = candidate;
2136
+ break;
2137
+ }
2138
+ acc += candidate.length;
2139
+ }
2140
+ const d = Math.max(0, Math.min(seg.length, target - acc));
2725
2141
  return {
2726
- pts,
2727
- pathLength,
2728
- tip,
2729
- dir,
2730
- perp,
2731
- segLength
2142
+ point: vecAdd(seg.start, vecScale(seg.along, d)),
2143
+ along: seg.along,
2144
+ perp: seg.perp
2732
2145
  };
2733
2146
  }
2734
2147
  /**
2735
- * Clamps and rounds a smooth-resolution option (samples per segment) onto
2736
- * `[MIN_SMOOTH_RESOLUTION, MAX_SMOOTH_RESOLUTION]`, falling back to `fallback`
2737
- * for non-finite input. The default varies per measure, so it's passed in.
2738
- */
2739
- function normalizeSmoothResolution$2(value, fallback) {
2740
- if (!Number.isFinite(value)) return fallback;
2741
- return Math.min(64, Math.max(2, Math.round(value)));
2742
- }
2743
- /**
2744
- * Shared param descriptors for {@link SmoothLineOptions}, modeled on
2745
- * `SMOOTH_PATH_PARAMS` (cm99-generic-graphics/params.ts). Measures that add
2746
- * their own params should spread these at the top of the array.
2747
- */
2748
- const SMOOTH_LINE_PARAMS = [{
2749
- key: "smooth",
2750
- label: "Smooth",
2751
- description: "Round the line's corners by curving the path through the control points.",
2752
- type: "boolean"
2753
- }, {
2754
- key: "smoothResolution",
2755
- presentationTier: "advanced",
2756
- label: "Smooth resolution",
2757
- description: "Number of samples per segment when smooth mode is enabled.",
2758
- type: "number",
2759
- min: 2,
2760
- max: 64,
2761
- step: 1,
2762
- visibleWhen: (opts) => Boolean(opts.smooth)
2763
- }];
2764
- /**
2765
- * Applies {@link SmoothLineOptions} to projected vertices `verts`: runs
2766
- * {@link catmullRom} at the resolved (clamped) sample resolution when `smooth`
2767
- * is set, else returns `verts` unchanged.
2148
+ * Frames at the two extreme ends of a polyline (first and last non-degenerate
2149
+ * segment), with `along` pointing OUTWARD — past the start, or past the end —
2150
+ * so callers can place something just beyond the line's tips. Either or both
2151
+ * keys are absent when the polyline has no non-degenerate segment.
2768
2152
  */
2769
- function smoothLineVerts(verts, options, defaultResolution) {
2770
- return options.smooth ? catmullRom(verts, normalizeSmoothResolution$2(options.smoothResolution ?? defaultResolution, defaultResolution)) : verts;
2153
+ function endFrames(verts) {
2154
+ const { segments } = polylineSegments(verts);
2155
+ if (segments.length === 0) return {};
2156
+ const first = segments[0];
2157
+ const last = segments[segments.length - 1];
2158
+ const startAlong = vecScale(first.along, -1);
2159
+ const endPoint = vecAdd(last.start, vecScale(last.along, last.length));
2160
+ return {
2161
+ start: {
2162
+ point: first.start,
2163
+ along: startAlong,
2164
+ perp: [-startAlong[1], startAlong[0]]
2165
+ },
2166
+ end: {
2167
+ point: endPoint,
2168
+ along: last.along,
2169
+ perp: last.perp
2170
+ }
2171
+ };
2771
2172
  }
2772
2173
  /**
2773
- * Resamples a path into equal arc-length segments.
2774
- *
2775
- * This is useful for a repeated motif (such as a fortified tooth or FLOT
2776
- * scallop) after smoothing. Catmull-Rom samples are evenly spaced in spline
2777
- * parameter, not distance, so using them directly as motif boundaries causes
2778
- * the motifs to bunch up where the curve's samples are closer together.
2174
+ * Resolves both tips' {@link EndTangent}s from the polyline's first and last
2175
+ * non-degenerate segments. Unlike {@link endFrames}, neither tangent is
2176
+ * negated for "outward" — both `perp`s derive from each segment's own forward
2177
+ * direction, so "above the line" (`+perp`) reads the same at both ends
2178
+ * instead of flipping at the start.
2779
2179
  */
2780
- function evenlySpacePath(points, targetSegmentLength) {
2781
- if (points.length < 2) return points;
2782
- const segmentLengths = [];
2783
- let totalLength = 0;
2784
- for (let i = 0; i < points.length - 1; i++) {
2785
- const length = vecMag(vecSub(points[i + 1], points[i]));
2786
- segmentLengths.push(length);
2787
- totalLength += length;
2788
- }
2789
- if (totalLength < 1e-6) return [points[0]];
2790
- const segmentCount = Math.max(1, Math.round(totalLength / targetSegmentLength));
2791
- const segmentLength = totalLength / segmentCount;
2792
- const out = [points[0]];
2793
- let sourceIndex = 0;
2794
- let sourceStart = points[0];
2795
- let consumed = 0;
2796
- for (let segment = 1; segment < segmentCount; segment++) {
2797
- const target = segment * segmentLength;
2798
- while (sourceIndex < segmentLengths.length - 1 && consumed + segmentLengths[sourceIndex] < target) {
2799
- consumed += segmentLengths[sourceIndex];
2800
- sourceIndex++;
2801
- sourceStart = points[sourceIndex];
2180
+ function endTangents(verts) {
2181
+ const { segments } = polylineSegments(verts);
2182
+ if (segments.length === 0) return {};
2183
+ const first = segments[0];
2184
+ const last = segments[segments.length - 1];
2185
+ const endPoint = vecAdd(last.start, vecScale(last.along, last.length));
2186
+ return {
2187
+ start: {
2188
+ point: first.start,
2189
+ interior: first.along,
2190
+ perp: first.perp,
2191
+ outwardX: -first.along[0]
2192
+ },
2193
+ end: {
2194
+ point: endPoint,
2195
+ interior: vecScale(last.along, -1),
2196
+ perp: last.perp,
2197
+ outwardX: last.along[0]
2802
2198
  }
2803
- const length = segmentLengths[sourceIndex];
2199
+ };
2200
+ }
2201
+ /** Nearest contact point on a polyline for a dragged label anchor. */
2202
+ function nearestPointOnPolyline(verts, target) {
2203
+ let nearest;
2204
+ let totalArc = 0;
2205
+ for (let i = 0; i < verts.length - 1; i++) {
2206
+ const a = verts[i];
2207
+ const b = verts[i + 1];
2208
+ const seg = vecSub(b, a);
2209
+ const length = vecMag(seg);
2804
2210
  if (length < 1e-6) continue;
2805
- const t = (target - consumed) / length;
2806
- const sourceEnd = points[sourceIndex + 1];
2807
- out.push([sourceStart[0] + (sourceEnd[0] - sourceStart[0]) * t, sourceStart[1] + (sourceEnd[1] - sourceStart[1]) * t]);
2211
+ const along = vecScale(seg, 1 / length);
2212
+ const t = Math.max(0, Math.min(length, vecDot(vecSub(target, a), along)));
2213
+ const distance = vecMag(vecSub(target, vecAdd(a, vecScale(along, t))));
2214
+ if (!nearest || distance < nearest.distance) nearest = {
2215
+ along,
2216
+ arc: totalArc + t,
2217
+ distance,
2218
+ edge: [a, b]
2219
+ };
2220
+ totalArc += length;
2808
2221
  }
2809
- out.push(points[points.length - 1]);
2810
- return out;
2222
+ return nearest ? {
2223
+ ...nearest,
2224
+ totalArc
2225
+ } : void 0;
2811
2226
  }
2812
- /** One centripetal Catmull-Rom sample at parameter `t` over control points `p0..p3`. */
2813
- function catmullRomAt(p0, p1, p2, p3, t) {
2814
- const t2 = t * t;
2815
- const t3 = t2 * t;
2816
- return [.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), .5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3)];
2227
+ /**
2228
+ * Half-extent of a possibly rotated `textWidth × textHeight` label box along
2229
+ * `direction` (the box's support function): a boundary running parallel to the
2230
+ * text needs roughly the label's width, one running perpendicular roughly its
2231
+ * height. `rotation` is the label's stored rotation — adapters render the
2232
+ * on-screen angle as `π − rotation`, and the absolute-value projections make
2233
+ * the result invariant under that sign convention.
2234
+ */
2235
+ function labelHalfExtent(direction, textWidth, textHeight, rotation) {
2236
+ const [widthAxis, heightAxis] = labelBoxAxes(rotation);
2237
+ return boxSupport(direction, textWidth, textHeight, widthAxis, heightAxis);
2238
+ }
2239
+ /** The label box's local axes for a stored `rotation`: the along-text width
2240
+ * axis and its perpendicular height axis. */
2241
+ function labelBoxAxes(rotation) {
2242
+ const widthAxis = [Math.cos(rotation), Math.sin(rotation)];
2243
+ return [widthAxis, [-widthAxis[1], widthAxis[0]]];
2244
+ }
2245
+ /** {@link labelHalfExtent} over already-resolved box axes. */
2246
+ function boxSupport(direction, textWidth, textHeight, widthAxis, heightAxis) {
2247
+ return .5 * (textWidth * Math.abs(vecDot(direction, widthAxis)) + textHeight * Math.abs(vecDot(direction, heightAxis)));
2817
2248
  }
2818
2249
  /**
2819
- * Centripetal Catmull-Rom spline through `points`, sampled `samples` times per
2820
- * segment. Endpoints are duplicated so the curve passes through them, so the
2821
- * first and last points are preserved exactly. With `samples <= 1` (or fewer
2822
- * than 3 points) it returns the input unchanged.
2250
+ * Whether a `textWidth × textHeight` label box centered at `anchor` with
2251
+ * stored `rotation` overlaps the finite segment `[a, b]` — an exact
2252
+ * separating-axis test over the box's two axes plus the segment's normal.
2253
+ * The normal support alone (see {@link labelHalfExtent}) only tests against
2254
+ * the segment's infinite line: past an endpoint the anchor→contact offset is
2255
+ * tangential and that scalar comparison wrongly reads as clear, so masking
2256
+ * decisions must use this finite-segment test instead.
2823
2257
  */
2824
- function catmullRom(points, samples) {
2825
- if (points.length < 3 || samples <= 1) return points;
2826
- const pts = [
2827
- points[0],
2828
- ...points,
2829
- points[points.length - 1]
2830
- ];
2831
- const out = [points[0]];
2832
- for (let i = 1; i < pts.length - 2; i++) {
2833
- const p0 = pts[i - 1];
2834
- const p1 = pts[i];
2835
- const p2 = pts[i + 1];
2836
- const p3 = pts[i + 2];
2837
- for (let s = 1; s <= samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
2258
+ function labelBoxIntersectsSegment(anchor, a, b, textWidth, textHeight, rotation) {
2259
+ const seg = vecSub(b, a);
2260
+ const length = vecMag(seg);
2261
+ const [widthAxis, heightAxis] = labelBoxAxes(rotation);
2262
+ const ra = vecSub(a, anchor);
2263
+ if (length < 1e-6) return Math.abs(vecDot(ra, widthAxis)) <= textWidth / 2 && Math.abs(vecDot(ra, heightAxis)) <= textHeight / 2;
2264
+ const along = vecScale(seg, 1 / length);
2265
+ const normal = [-along[1], along[0]];
2266
+ if (Math.abs(vecDot(ra, normal)) > boxSupport(normal, textWidth, textHeight, widthAxis, heightAxis)) return false;
2267
+ const rb = vecSub(b, anchor);
2268
+ for (const [axis, half] of [[widthAxis, textWidth / 2], [heightAxis, textHeight / 2]]) {
2269
+ const pa = vecDot(ra, axis);
2270
+ const pb = vecDot(rb, axis);
2271
+ if (Math.min(pa, pb) > half || Math.max(pa, pb) < -half) return false;
2838
2272
  }
2839
- return out;
2273
+ return true;
2840
2274
  }
2841
2275
  /**
2842
- * Closed centripetal Catmull-Rom: smooths a polygon ring through every vertex,
2843
- * wrapping the control points around the seam. Returns a ring whose first and
2844
- * last positions coincide (explicit closure). With `samples <= 1` (or fewer
2845
- * than 3 points) it returns the input unchanged.
2276
+ * Splits the polyline `points` into the pieces that fall outside a
2277
+ * `2*halfWidth × 2*halfHeight` label box centered at `anchor` with stored
2278
+ * `rotation` — the clipping counterpart to {@link labelBoxIntersectsSegment},
2279
+ * for ornament geometry (barbs, ticks) that must break around a glyph rather
2280
+ * than merely decide whether it collides.
2281
+ *
2282
+ * Each segment is clipped in the box's own frame with a two-slab parametric
2283
+ * test; pieces that rejoin across a shared vertex are merged so a polyline
2284
+ * crossing the box mid-span stays one line string per surviving run instead of
2285
+ * fragmenting at every interior vertex.
2846
2286
  */
2847
- function closedCatmullRom(points, samples) {
2848
- const n = points.length;
2849
- if (n < 3 || samples <= 1) return points;
2287
+ function clipPolylineOutsideLabelBox(points, anchor, halfWidth, halfHeight, rotation) {
2288
+ const [widthAxis, heightAxis] = labelBoxAxes(rotation);
2850
2289
  const out = [];
2851
- for (let i = 0; i < n; i++) {
2852
- const p0 = points[(i - 1 + n) % n];
2853
- const p1 = points[i];
2854
- const p2 = points[(i + 1) % n];
2855
- const p3 = points[(i + 2) % n];
2856
- for (let s = 0; s < samples; s++) out.push(catmullRomAt(p0, p1, p2, p3, s / samples));
2290
+ const append = (piece) => {
2291
+ if (piece.length < 2 || vecMag(vecSub(piece[1], piece[0])) < 1e-6) return;
2292
+ const previous = out.at(-1);
2293
+ if (previous && vecMag(vecSub(previous.at(-1), piece[0])) < 1e-6) previous.push(...piece.slice(1));
2294
+ else out.push(piece);
2295
+ };
2296
+ for (let i = 1; i < points.length; i++) {
2297
+ const a = points[i - 1];
2298
+ const b = points[i];
2299
+ const delta = vecSub(b, a);
2300
+ const relative = vecSub(a, anchor);
2301
+ const origin = [vecDot(relative, widthAxis), vecDot(relative, heightAxis)];
2302
+ const direction = [vecDot(delta, widthAxis), vecDot(delta, heightAxis)];
2303
+ let enter = 0;
2304
+ let exit = 1;
2305
+ let intersects = true;
2306
+ for (const [coordinate, rate, halfExtent] of [[
2307
+ origin[0],
2308
+ direction[0],
2309
+ halfWidth
2310
+ ], [
2311
+ origin[1],
2312
+ direction[1],
2313
+ halfHeight
2314
+ ]]) {
2315
+ if (Math.abs(rate) < 1e-6) {
2316
+ if (Math.abs(coordinate) > halfExtent) intersects = false;
2317
+ continue;
2318
+ }
2319
+ const first = (-halfExtent - coordinate) / rate;
2320
+ const second = (halfExtent - coordinate) / rate;
2321
+ enter = Math.max(enter, Math.min(first, second));
2322
+ exit = Math.min(exit, Math.max(first, second));
2323
+ if (enter > exit) intersects = false;
2324
+ }
2325
+ if (!intersects || exit < 0 || enter > 1) {
2326
+ append([a, b]);
2327
+ continue;
2328
+ }
2329
+ const clippedEnter = Math.max(0, enter);
2330
+ const clippedExit = Math.min(1, exit);
2331
+ const pointAt = (t) => t <= 0 ? a : t >= 1 ? b : vecAdd(a, vecScale(delta, t));
2332
+ if (clippedEnter > 0) append([a, pointAt(clippedEnter)]);
2333
+ if (clippedExit < 1) append([pointAt(clippedExit), b]);
2857
2334
  }
2858
- out.push([...out[0]]);
2859
2335
  return out;
2860
2336
  }
2861
- //#endregion
2862
- //#region src/internal/line-labels.ts
2337
+ /** Default extra clearance for an intrinsic label that interrupts a stroke. */
2338
+ const DEFAULT_LABEL_KNOCKOUT_PADDING = .3;
2339
+ /** Field N descriptor shared by Line1 graphics that opt into hostile endpoint markers. */
2340
+ const HOSTILE_LINE_AMPLIFIER = {
2341
+ key: "N",
2342
+ label: "Hostile (ENY) marker",
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
+ };
2863
2358
  /**
2864
- * Stored label rotation for text running along projected direction `dir`.
2865
- * Adapters render feature rotation with their `Math.PI - rotation` convention
2866
- * (and Y-up projected space maps to Y-down screen as `-φ`), so we derive the
2867
- * on-screen angle, keep its baseline left-to-right, then convert back. Sign-
2868
- * independent: `labelRotationAlong(dir)` and `labelRotationAlong(vecScale(dir,
2869
- * -1))` produce the same rotation, since the flip only depends on `dir`'s
2870
- * quadrant, not the labeling caller's notion of "forward".
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.
2871
2364
  */
2872
- function labelRotationAlong(dir) {
2873
- const rendered = -Math.atan2(dir[1], dir[0]);
2874
- return normalizeRadians(Math.PI - keepTextLeftToRight(rendered));
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 {};
2875
2370
  }
2876
- /** Resolves two echelon-unit rows from a caller-selected set of amplifiers. */
2877
- function unitLabelsFromAmplifiers(amplifiers, fields) {
2878
- return [{
2879
- side: 1,
2880
- designator: amplifiers[fields.unit1.designator] ?? "",
2881
- country: amplifiers[fields.unit1.country] ?? ""
2882
- }, {
2883
- side: -1,
2884
- designator: amplifiers[fields.unit2.designator] ?? "",
2885
- country: amplifiers[fields.unit2.country] ?? ""
2886
- }];
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
+ });
2887
2389
  }
2888
- /** Text size shared by an echelon glyph and its associated unit labels. */
2889
- function echelonUnitLabelSize(echelonHeight, echelonSizePixels, metersPerPixel) {
2890
- const screenAnchored = echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0;
2891
- return {
2892
- properties: screenAnchored ? { textSizePixels: echelonSizePixels } : { textSizeMeters: echelonHeight },
2893
- screenAnchored
2894
- };
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;
2895
2400
  }
2896
2401
  /**
2897
- * Emits a single combined `T (AS)` label per unit side (APP-6/MIL-STD-2525):
2898
- * designator and country code share one text row rather than stacking.
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.
2899
2410
  */
2900
- function pushUnitLabels(out, frame, units) {
2901
- const { center, perp, offset, rotation, labelSize } = frame;
2902
- for (const { side, designator, country, textAnchor, amplifierField } of units) {
2903
- let text;
2904
- if (designator.length > 0 && country.length > 0) text = `${designator} (${country})`;
2905
- else if (designator.length > 0) text = designator;
2906
- else if (country.length > 0) text = `(${country})`;
2907
- else continue;
2908
- pushLabel(out, vecAdd(center, vecScale(perp, side * offset)), text, rotation, labelSize, textAnchor, amplifierField);
2909
- }
2411
+ function resolveLabelOffsetMeters(options, ratio, fallbackMeters = 50) {
2412
+ const h = textHeight(options);
2413
+ return h !== void 0 ? ratio * h : fallbackMeters;
2910
2414
  }
2911
2415
  /**
2912
- * Default clearance between an echelon glyph and its unit labels, as a ratio of
2913
- * the glyph height — the value Boundary declares as its `labelPadding` default,
2914
- * shared so other echelon-carrying lines land on the same spacing.
2915
- */
2916
- const ECHELON_UNIT_LABEL_PADDING = .4;
2917
- /**
2918
- * Emits Boundary-style unit labels around echelon groups. Along-line groups
2919
- * straddle the line; standard-orientation groups on north/south segments form
2920
- * one row crossing it, anchored outward from the glyph.
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.
2921
2429
  */
2922
- function pushEchelonUnitLabels(out, groups, units, echelonHeight, labelPadding, labelSize) {
2923
- for (const { center, along, perp, rotateGroup, readingAxis, glyphWidth } of groups) {
2924
- if (rotateGroup) {
2925
- const sign = vecDot(perp, readingAxis) >= 0 ? 1 : -1;
2926
- const clearance = glyphWidth / 2 + Math.max(0, labelPadding) * echelonHeight;
2927
- pushUnitLabels(out, {
2928
- center,
2929
- perp: vecScale(readingAxis, sign),
2930
- offset: clearance,
2931
- rotation: labelRotationAlong(readingAxis),
2932
- labelSize
2933
- }, units.map((unit) => ({
2934
- ...unit,
2935
- textAnchor: unit.side === sign ? "start" : "end"
2936
- })));
2937
- continue;
2938
- }
2939
- pushUnitLabels(out, {
2940
- center,
2941
- perp,
2942
- offset: (.45 + Math.max(0, labelPadding)) * echelonHeight,
2943
- rotation: labelRotationAlong(along),
2944
- labelSize
2945
- }, units);
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");
2946
2440
  }
2947
2441
  }
2948
2442
  /**
2949
- * Walks `verts` (projected meters) into non-degenerate {@link LineSegment}s,
2950
- * skipping coincident vertices (ADR-0014, silent) the same way Boundary's
2951
- * gap-carving does. Empty or fully-degenerate input yields `totalLength: 0`
2952
- * and no segments.
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.
2953
2446
  */
2954
- function polylineSegments(verts) {
2955
- const segments = [];
2956
- let totalLength = 0;
2957
- for (let i = 0; i < verts.length - 1; i++) {
2958
- const a = verts[i];
2959
- const seg = vecSub(verts[i + 1], a);
2960
- const length = vecMag(seg);
2961
- if (length < 1e-6) continue;
2962
- const along = vecNorm(seg);
2963
- segments.push({
2964
- start: a,
2965
- along,
2966
- perp: [-along[1], along[0]],
2967
- length
2968
- });
2969
- totalLength += length;
2970
- }
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
+ };
2971
2487
  return {
2972
- segments,
2973
- totalLength
2488
+ width: measured.widthCssPixels * constructionScale,
2489
+ height: measured.heightCssPixels * constructionScale
2974
2490
  };
2975
2491
  }
2976
- /** Resolves an along-line position onto the valid start-to-end range. */
2977
- function clampLinePosition(value, fallback) {
2978
- return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
2979
- }
2980
2492
  /**
2981
- * Resolves a label/group repetition count to a positive whole number. Takes
2982
- * `unknown` because the params panel's `visibleWhen` callbacks receive loosely
2983
- * typed options; non-finite input falls back to a single group.
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.
2984
2498
  */
2985
- function resolveLabelRepetitions(value) {
2986
- const numeric = Number(value ?? 1);
2987
- return Number.isFinite(numeric) ? Math.max(1, Math.round(numeric)) : 1;
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;
2988
2503
  }
2989
2504
  /**
2990
- * Resolves the point at `fraction` (clamped to `[0, 1]`) of `totalLength`
2991
- * along `segments`, walking cumulative arc length exactly like Boundary's
2992
- * label-group placement: the segment containing the target distance absorbs
2993
- * any floating-point overshoot, and the last segment absorbs a fraction of 1.
2994
- * Returns `undefined` for an empty or degenerate (`totalLength < EPSILON`)
2995
- * polyline.
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.
2996
2508
  */
2997
- function pointAlongPolyline(segments, totalLength, fraction) {
2998
- if (segments.length === 0 || totalLength < 1e-6) return void 0;
2999
- const target = Math.min(1, Math.max(0, fraction)) * totalLength;
3000
- let acc = 0;
3001
- let seg = segments[segments.length - 1];
3002
- for (const candidate of segments) {
3003
- if (target <= acc + candidate.length) {
3004
- seg = candidate;
3005
- break;
3006
- }
3007
- acc += candidate.length;
3008
- }
3009
- const d = Math.max(0, Math.min(seg.length, target - acc));
3010
- return {
3011
- point: vecAdd(seg.start, vecScale(seg.along, d)),
3012
- along: seg.along,
3013
- perp: seg.perp
3014
- };
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;
3015
2518
  }
3016
2519
  /**
3017
- * Frames at the two extreme ends of a polyline (first and last non-degenerate
3018
- * segment), with `along` pointing OUTWARD — past the start, or past the end —
3019
- * so callers can place something just beyond the line's tips. Either or both
3020
- * keys are absent when the polyline has no non-degenerate segment.
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.
3021
2524
  */
3022
- function endFrames(verts) {
3023
- const { segments } = polylineSegments(verts);
3024
- if (segments.length === 0) return {};
3025
- const first = segments[0];
3026
- const last = segments[segments.length - 1];
3027
- const startAlong = vecScale(first.along, -1);
3028
- const endPoint = vecAdd(last.start, vecScale(last.along, last.length));
3029
- return {
3030
- start: {
3031
- point: first.start,
3032
- along: startAlong,
3033
- perp: [-startAlong[1], startAlong[0]]
3034
- },
3035
- end: {
3036
- point: endPoint,
3037
- along: last.along,
3038
- perp: last.perp
3039
- }
3040
- };
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
+ }
3041
2538
  }
3042
2539
  /**
3043
- * Resolves both tips' {@link EndTangent}s from the polyline's first and last
3044
- * non-degenerate segments. Unlike {@link endFrames}, neither tangent is
3045
- * negated for "outward" — both `perp`s derive from each segment's own forward
3046
- * direction, so "above the line" (`+perp`) reads the same at both ends
3047
- * instead of flipping at the start.
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.
3048
2548
  */
3049
- function endTangents(verts) {
3050
- const { segments } = polylineSegments(verts);
3051
- if (segments.length === 0) return {};
3052
- const first = segments[0];
3053
- const last = segments[segments.length - 1];
3054
- const endPoint = vecAdd(last.start, vecScale(last.along, last.length));
3055
- return {
3056
- start: {
3057
- point: first.start,
3058
- interior: first.along,
3059
- perp: first.perp,
3060
- outwardX: -first.along[0]
3061
- },
3062
- end: {
3063
- point: endPoint,
3064
- interior: vecScale(last.along, -1),
3065
- perp: last.perp,
3066
- outwardX: last.along[0]
3067
- }
3068
- };
3069
- }
3070
- /** Nearest contact point on a polyline for a dragged label anchor. */
3071
- function nearestPointOnPolyline(verts, target) {
3072
- let nearest;
3073
- let totalArc = 0;
3074
- for (let i = 0; i < verts.length - 1; i++) {
3075
- const a = verts[i];
3076
- const b = verts[i + 1];
3077
- const seg = vecSub(b, a);
3078
- const length = vecMag(seg);
3079
- if (length < 1e-6) continue;
3080
- const along = vecScale(seg, 1 / length);
3081
- const t = Math.max(0, Math.min(length, vecDot(vecSub(target, a), along)));
3082
- const distance = vecMag(vecSub(target, vecAdd(a, vecScale(along, t))));
3083
- if (!nearest || distance < nearest.distance) nearest = {
3084
- along,
3085
- arc: totalArc + t,
3086
- distance,
3087
- edge: [a, b]
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"
3088
2563
  };
3089
- totalArc += length;
3090
2564
  }
3091
- return nearest ? {
3092
- ...nearest,
3093
- totalArc
3094
- } : void 0;
2565
+ return frames;
3095
2566
  }
3096
2567
  /**
3097
- * Half-extent of a possibly rotated `textWidth × textHeight` label box along
3098
- * `direction` (the box's support function): a boundary running parallel to the
3099
- * text needs roughly the label's width, one running perpendicular roughly its
3100
- * height. `rotation` is the label's stored rotation — adapters render the
3101
- * on-screen angle as `π − rotation`, and the absolute-value projections make
3102
- * the result invariant under that sign convention.
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.
3103
2576
  */
3104
- function labelHalfExtent(direction, textWidth, textHeight, rotation) {
3105
- const [widthAxis, heightAxis] = labelBoxAxes(rotation);
3106
- return boxSupport(direction, textWidth, textHeight, widthAxis, heightAxis);
3107
- }
3108
- /** The label box's local axes for a stored `rotation`: the along-text width
3109
- * axis and its perpendicular height axis. */
3110
- function labelBoxAxes(rotation) {
3111
- const widthAxis = [Math.cos(rotation), Math.sin(rotation)];
3112
- return [widthAxis, [-widthAxis[1], widthAxis[0]]];
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
+ }
3113
2587
  }
3114
- /** {@link labelHalfExtent} over already-resolved box axes. */
3115
- function boxSupport(direction, textWidth, textHeight, widthAxis, heightAxis) {
3116
- return .5 * (textWidth * Math.abs(vecDot(direction, widthAxis)) + textHeight * Math.abs(vecDot(direction, heightAxis)));
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
+ };
3117
2623
  }
2624
+ //#endregion
2625
+ //#region src/generators/cm34-mission-tasks/shared.ts
3118
2626
  /**
3119
- * Whether a `textWidth × textHeight` label box centered at `anchor` with
3120
- * stored `rotation` overlaps the finite segment `[a, b]` — an exact
3121
- * separating-axis test over the box's two axes plus the segment's normal.
3122
- * The normal support alone (see {@link labelHalfExtent}) only tests against
3123
- * the segment's infinite line: past an endpoint the anchor→contact offset is
3124
- * tangential and that scalar comparison wrongly reads as clear, so masking
3125
- * decisions must use this finite-segment test instead.
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.
3126
2631
  */
3127
- function labelBoxIntersectsSegment(anchor, a, b, textWidth, textHeight, rotation) {
3128
- const seg = vecSub(b, a);
3129
- const length = vecMag(seg);
3130
- const [widthAxis, heightAxis] = labelBoxAxes(rotation);
3131
- const ra = vecSub(a, anchor);
3132
- if (length < 1e-6) return Math.abs(vecDot(ra, widthAxis)) <= textWidth / 2 && Math.abs(vecDot(ra, heightAxis)) <= textHeight / 2;
3133
- const along = vecScale(seg, 1 / length);
3134
- const normal = [-along[1], along[0]];
3135
- if (Math.abs(vecDot(ra, normal)) > boxSupport(normal, textWidth, textHeight, widthAxis, heightAxis)) return false;
3136
- const rb = vecSub(b, anchor);
3137
- for (const [axis, half] of [[widthAxis, textWidth / 2], [heightAxis, textHeight / 2]]) {
3138
- const pa = vecDot(ra, axis);
3139
- const pb = vecDot(rb, axis);
3140
- if (Math.min(pa, pb) > half || Math.max(pa, pb) < -half) return false;
3141
- }
3142
- return true;
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
+ ];
3143
2642
  }
3144
2643
  /**
3145
- * Splits the polyline `points` into the pieces that fall outside a
3146
- * `2*halfWidth × 2*halfHeight` label box centered at `anchor` with stored
3147
- * `rotation` — the clipping counterpart to {@link labelBoxIntersectsSegment},
3148
- * for ornament geometry (barbs, ticks) that must break around a glyph rather
3149
- * than merely decide whether it collides.
3150
- *
3151
- * Each segment is clipped in the box's own frame with a two-slab parametric
3152
- * test; pieces that rejoin across a shared vertex are merged so a polyline
3153
- * crossing the box mid-span stays one line string per surviving run instead of
3154
- * fragmenting at every interior vertex.
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.
3155
2649
  */
3156
- function clipPolylineOutsideLabelBox(points, anchor, halfWidth, halfHeight, rotation) {
3157
- const [widthAxis, heightAxis] = labelBoxAxes(rotation);
3158
- const out = [];
3159
- const append = (piece) => {
3160
- if (piece.length < 2 || vecMag(vecSub(piece[1], piece[0])) < 1e-6) return;
3161
- const previous = out.at(-1);
3162
- if (previous && vecMag(vecSub(previous.at(-1), piece[0])) < 1e-6) previous.push(...piece.slice(1));
3163
- else out.push(piece);
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
+ }
3164
2664
  };
3165
- for (let i = 1; i < points.length; i++) {
3166
- const a = points[i - 1];
3167
- const b = points[i];
3168
- const delta = vecSub(b, a);
3169
- const relative = vecSub(a, anchor);
3170
- const origin = [vecDot(relative, widthAxis), vecDot(relative, heightAxis)];
3171
- const direction = [vecDot(delta, widthAxis), vecDot(delta, heightAxis)];
3172
- let enter = 0;
3173
- let exit = 1;
3174
- let intersects = true;
3175
- for (const [coordinate, rate, halfExtent] of [[
3176
- origin[0],
3177
- direction[0],
3178
- halfWidth
3179
- ], [
3180
- origin[1],
3181
- direction[1],
3182
- halfHeight
3183
- ]]) {
3184
- if (Math.abs(rate) < 1e-6) {
3185
- if (Math.abs(coordinate) > halfExtent) intersects = false;
3186
- continue;
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
+ ]
3187
2744
  }
3188
- const first = (-halfExtent - coordinate) / rate;
3189
- const second = (halfExtent - coordinate) / rate;
3190
- enter = Math.max(enter, Math.min(first, second));
3191
- exit = Math.min(exit, Math.max(first, second));
3192
- if (enter > exit) intersects = false;
2745
+ }, createLabelFeature(rearMidpoint, config.labelText, labelRotation, config.labelOptions)]
2746
+ };
2747
+ }
2748
+ //#endregion
2749
+ //#region src/generators/cm34-mission-tasks/cross-task-shared.ts
2750
+ const DEFAULT_CROSS_TASK_OPTIONS = {
2751
+ sizePixels: 80,
2752
+ rotation: 0,
2753
+ crossAngle: 2 * Math.atan(.55) * 180 / Math.PI,
2754
+ labelPadding: DEFAULT_LABEL_KNOCKOUT_PADDING
2755
+ };
2756
+ const CROSS_TASK_PARAMS = [
2757
+ {
2758
+ key: "crossAngle",
2759
+ presentationTier: "advanced",
2760
+ label: "Cross angle",
2761
+ description: "Opening angle between the two right-hand arms. Changes height while preserving width.",
2762
+ type: "number",
2763
+ min: 10,
2764
+ max: 150,
2765
+ step: 1,
2766
+ unit: "°"
2767
+ },
2768
+ {
2769
+ key: "sizePixels",
2770
+ label: "Size",
2771
+ description: "Width of the complete symbol in screen pixels.",
2772
+ type: "number",
2773
+ min: 16,
2774
+ max: 400,
2775
+ step: 1,
2776
+ unit: "px"
2777
+ },
2778
+ {
2779
+ key: "labelPadding",
2780
+ presentationTier: "advanced",
2781
+ label: "Label padding",
2782
+ description: "Extra clearance around the doctrinal letter, as a ratio of its resolved height.",
2783
+ type: "number",
2784
+ min: 0,
2785
+ max: 2,
2786
+ step: .05
2787
+ },
2788
+ {
2789
+ key: "sizeMeters",
2790
+ label: "Size",
2791
+ description: "Overall width of the static symbol.",
2792
+ type: "number",
2793
+ min: 10,
2794
+ max: 1e5,
2795
+ step: 10,
2796
+ unit: "m"
2797
+ },
2798
+ {
2799
+ key: "rotation",
2800
+ label: "Rotation",
2801
+ description: "Clockwise rotation of the static symbol.",
2802
+ type: "number",
2803
+ min: 0,
2804
+ max: 360,
2805
+ step: 1,
2806
+ unit: "°"
2807
+ }
2808
+ ];
2809
+ function transformCrossTaskOptions(options, { scale, rotationRadians }) {
2810
+ const patch = {};
2811
+ 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;
2812
+ else patch.sizeMeters = options.sizeMeters * scale;
2813
+ if (rotationRadians !== 0 && Number.isFinite(rotationRadians)) patch.rotation = normalizeDegrees((options.rotation ?? 0) + rotationRadians * 180 / Math.PI);
2814
+ return Object.keys(patch).length ? patch : void 0;
2815
+ }
2816
+ /** The doctrinal letter each cross task centers. */
2817
+ const CROSS_TASK_GLYPHS = {
2818
+ destroy: "D",
2819
+ defeat: "D",
2820
+ neutralize: "N",
2821
+ suppress: "S"
2822
+ };
2823
+ /** Milsymbol 3.0.4, iconparts/tactical-points.js: TP.DESTROY / TP.NEUTRALIZE / TP.SUPPRESS.
2824
+ * Default to the 200 × 110 cross and 45-unit glyph; crossAngle adjusts its height. The center gap
2825
+ * follows resolved text metrics and padding rather than milsymbol’s fixed 30 units.
2826
+ * SVG's y=103 text anchor compensates for its font baseline; our labels are centered.
2827
+ */
2828
+ function createCrossTaskSymbol(coordinates, options, kind, context = {}) {
2829
+ const anchor = coordinates[0];
2830
+ if (!anchor) return {
2831
+ type: "FeatureCollection",
2832
+ features: []
2833
+ };
2834
+ const center = project(anchor[0], anchor[1]);
2835
+ const pixels = options.sizePixels ?? (options.sizeMeters === void 0 ? DEFAULT_CROSS_TASK_OPTIONS.sizePixels : void 0);
2836
+ const width = pixels !== void 0 && options.metersPerPixel !== void 0 && options.metersPerPixel > 0 ? pixels * options.metersPerPixel : options.sizeMeters ?? 1e3;
2837
+ if (!Number.isFinite(width) || width <= 0) return {
2838
+ type: "FeatureCollection",
2839
+ features: []
2840
+ };
2841
+ const scale = width / 200;
2842
+ const rotation = (options.rotation ?? 0) * Math.PI / 180;
2843
+ const cos = Math.cos(rotation);
2844
+ const sin = Math.sin(rotation);
2845
+ const local = (x, y) => unproject(center[0] + scale * (x * cos + y * sin), center[1] + scale * (-x * sin + y * cos));
2846
+ const glyph = CROSS_TASK_GLYPHS[kind];
2847
+ const labelOptions = {
2848
+ labelSize: 45 * scale,
2849
+ labelPadding: options.labelPadding
2850
+ };
2851
+ const labelContext = {
2852
+ ...context,
2853
+ labelSizeClampCssPixels: {
2854
+ min: 0,
2855
+ max: Number.MAX_VALUE
3193
2856
  }
3194
- if (!intersects || exit < 0 || enter > 1) {
3195
- append([a, b]);
3196
- continue;
2857
+ };
2858
+ const requestedAngle = options.crossAngle ?? DEFAULT_CROSS_TASK_OPTIONS.crossAngle;
2859
+ const angle = Number.isFinite(requestedAngle) ? Math.min(150, Math.max(10, requestedAngle)) : DEFAULT_CROSS_TASK_OPTIONS.crossAngle;
2860
+ const crossSlope = Math.tan(angle * Math.PI / 360);
2861
+ const diagonalScale = Math.hypot(1, crossSlope);
2862
+ const gap = Math.min(100, labelKnockoutHalfExtent(glyph, [1 / diagonalScale, crossSlope / diagonalScale], Math.PI, labelOptions, labelContext, DEFAULT_CROSS_TASK_OPTIONS.labelPadding) / (scale * diagonalScale));
2863
+ const label = createLabelFeature(center, glyph, Math.PI - rotation, labelOptions);
2864
+ if (pixels !== void 0) {
2865
+ delete label.properties.textSizeMeters;
2866
+ label.properties.textSizePixels = pixels * 45 / 200;
2867
+ } else label.properties.textMaxSizePixels = Number.MAX_SAFE_INTEGER;
2868
+ const resolution = context.constructionMetersPerCssPixel ?? options.metersPerPixel;
2869
+ const strokeWidth = (pixels ?? (resolution && resolution > 0 ? width / resolution : 80)) * (kind === "defeat" ? 8 : 4) / 200;
2870
+ if (gap >= 100) return {
2871
+ type: "FeatureCollection",
2872
+ features: [label]
2873
+ };
2874
+ if (kind === "defeat") {
2875
+ const shafts = [];
2876
+ const heads = [];
2877
+ const headLength = Math.min(32, (100 - gap) * diagonalScale * .65);
2878
+ const halfHeadWidth = headLength * .56;
2879
+ for (const xSign of [-1, 1]) for (const ySign of [-1, 1]) {
2880
+ const ux = xSign / diagonalScale;
2881
+ const uy = ySign * crossSlope / diagonalScale;
2882
+ const tipX = xSign * gap;
2883
+ const tipY = ySign * gap * crossSlope;
2884
+ const baseX = tipX + ux * headLength;
2885
+ const baseY = tipY + uy * headLength;
2886
+ shafts.push([local(xSign * 100, ySign * 100 * crossSlope), local(baseX, baseY)]);
2887
+ const tip = local(tipX, tipY);
2888
+ heads.push([[
2889
+ tip,
2890
+ local(baseX - uy * halfHeadWidth, baseY + ux * halfHeadWidth),
2891
+ local(baseX + uy * halfHeadWidth, baseY - ux * halfHeadWidth),
2892
+ tip
2893
+ ]]);
3197
2894
  }
3198
- const clippedEnter = Math.max(0, enter);
3199
- const clippedExit = Math.min(1, exit);
3200
- const pointAt = (t) => t <= 0 ? a : t >= 1 ? b : vecAdd(a, vecScale(delta, t));
3201
- if (clippedEnter > 0) append([a, pointAt(clippedEnter)]);
3202
- if (clippedExit < 1) append([pointAt(clippedExit), b]);
2895
+ return {
2896
+ type: "FeatureCollection",
2897
+ features: [
2898
+ {
2899
+ type: "Feature",
2900
+ properties: {
2901
+ part: "defeat",
2902
+ style: { strokeWidth }
2903
+ },
2904
+ geometry: {
2905
+ type: "MultiLineString",
2906
+ coordinates: shafts
2907
+ }
2908
+ },
2909
+ {
2910
+ type: "Feature",
2911
+ properties: {
2912
+ part: "arrowheads",
2913
+ fill: true,
2914
+ style: {
2915
+ ...SOLID_ACCENT_FILL,
2916
+ strokeWidth: 0,
2917
+ strokeDash: []
2918
+ }
2919
+ },
2920
+ geometry: {
2921
+ type: "MultiPolygon",
2922
+ coordinates: heads
2923
+ }
2924
+ },
2925
+ label
2926
+ ]
2927
+ };
3203
2928
  }
3204
- return out;
2929
+ const dashLength = 24;
2930
+ const diagonalLines = (slope, dashed) => {
2931
+ const lines = [];
2932
+ for (const [start, end] of [[gap, 100], [-100, -gap]]) {
2933
+ if (!dashed) {
2934
+ lines.push([local(start, start * slope), local(end, end * slope)]);
2935
+ continue;
2936
+ }
2937
+ const length = (end - start) * diagonalScale;
2938
+ for (let distance = 0; distance < length; distance += 34) {
2939
+ const x1 = start + distance / diagonalScale;
2940
+ const x2 = start + Math.min(distance + dashLength, length) / diagonalScale;
2941
+ lines.push([local(x1, x1 * slope), local(x2, x2 * slope)]);
2942
+ }
2943
+ }
2944
+ return lines;
2945
+ };
2946
+ return {
2947
+ type: "FeatureCollection",
2948
+ features: [
2949
+ {
2950
+ type: "Feature",
2951
+ properties: {
2952
+ part: kind,
2953
+ style: {
2954
+ strokeWidth,
2955
+ ...kind === "suppress" ? { strokeDash: [] } : {}
2956
+ }
2957
+ },
2958
+ geometry: {
2959
+ type: "MultiLineString",
2960
+ coordinates: diagonalLines(-crossSlope, kind === "suppress")
2961
+ }
2962
+ },
2963
+ {
2964
+ type: "Feature",
2965
+ properties: {
2966
+ part: `${kind}-rising`,
2967
+ style: {
2968
+ strokeWidth,
2969
+ ...kind !== "destroy" ? { strokeDash: [] } : {}
2970
+ }
2971
+ },
2972
+ geometry: {
2973
+ type: "MultiLineString",
2974
+ coordinates: diagonalLines(crossSlope, kind !== "destroy")
2975
+ }
2976
+ },
2977
+ label
2978
+ ]
2979
+ };
3205
2980
  }
3206
- /** Default extra clearance for an intrinsic label that interrupts a stroke. */
3207
- const DEFAULT_LABEL_KNOCKOUT_PADDING = .3;
3208
- /** Field N descriptor shared by Line1 graphics that opt into hostile endpoint markers. */
3209
- const HOSTILE_LINE_AMPLIFIER = {
2981
+ //#endregion
2982
+ //#region src/generators/cm34-mission-tasks/defeat.ts
2983
+ const DEFAULT_DEFEAT_OPTIONS = {
2984
+ ...DEFAULT_CROSS_TASK_OPTIONS,
2985
+ crossAngle: 80
2986
+ };
2987
+ const DEFEAT_METADATA = {
2988
+ id: "defeat",
2989
+ name: "Defeat",
2990
+ description: "Renders an enemy force unable or unwilling to achieve its objectives.",
2991
+ entity: "Mission Tasks",
2992
+ entityType: "Defeat",
2993
+ value: "344300",
2994
+ minCoordinates: 1,
2995
+ maxCoordinates: 1,
2996
+ geometry: "point",
2997
+ geometryTypes: [
2998
+ "MultiLineString",
2999
+ "MultiPolygon",
3000
+ "Point"
3001
+ ],
3002
+ paints: {
3003
+ stroke: true,
3004
+ fill: "fixed",
3005
+ text: true
3006
+ },
3007
+ drawRule: "Point2",
3008
+ uniformSizing: true,
3009
+ labelGeometryUsesResolvedMetrics: true,
3010
+ params: CROSS_TASK_PARAMS
3011
+ };
3012
+ /** Generates the Defeat mission task (344300) around its sole center anchor. */
3013
+ function createDefeatSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3014
+ return createCrossTaskSymbol(coordinates, {
3015
+ ...options,
3016
+ crossAngle: Number.isFinite(options.crossAngle) ? options.crossAngle : DEFAULT_DEFEAT_OPTIONS.crossAngle
3017
+ }, "defeat", context);
3018
+ }
3019
+ const DEFEAT = defineControlMeasure({
3020
+ metadata: DEFEAT_METADATA,
3021
+ generator: createDefeatSymbol,
3022
+ defaultOptions: DEFAULT_DEFEAT_OPTIONS,
3023
+ rule: staticPointDrawRule,
3024
+ transformOptions: transformCrossTaskOptions,
3025
+ previewSample: {
3026
+ controlPoints: [[0, 0]],
3027
+ options: { sizeMeters: 1e3 }
3028
+ }
3029
+ });
3030
+ //#endregion
3031
+ //#region src/generators/cm34-mission-tasks/suppress.ts
3032
+ const DEFAULT_SUPPRESS_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3033
+ const SUPPRESS_METADATA = {
3034
+ id: "suppress",
3035
+ name: "Suppress",
3036
+ description: "Temporarily degrades an enemy force or weapon system below the level needed to accomplish its mission.",
3037
+ entity: "Mission Tasks",
3038
+ entityType: "Suppress",
3039
+ value: "342800",
3040
+ minCoordinates: 1,
3041
+ maxCoordinates: 1,
3042
+ geometry: "point",
3043
+ geometryTypes: ["MultiLineString", "Point"],
3044
+ paints: {
3045
+ stroke: true,
3046
+ fill: "none",
3047
+ text: true
3048
+ },
3049
+ drawRule: "Point2",
3050
+ uniformSizing: true,
3051
+ labelGeometryUsesResolvedMetrics: true,
3052
+ params: CROSS_TASK_PARAMS
3053
+ };
3054
+ /** Generates the Suppress mission task (342800) around its sole center anchor. */
3055
+ function createSuppressSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3056
+ return createCrossTaskSymbol(coordinates, options, "suppress", context);
3057
+ }
3058
+ const SUPPRESS = defineControlMeasure({
3059
+ metadata: SUPPRESS_METADATA,
3060
+ generator: createSuppressSymbol,
3061
+ defaultOptions: DEFAULT_SUPPRESS_OPTIONS,
3062
+ rule: staticPointDrawRule,
3063
+ transformOptions: transformCrossTaskOptions,
3064
+ previewSample: {
3065
+ controlPoints: [[0, 0]],
3066
+ options: { sizeMeters: 1e3 }
3067
+ }
3068
+ });
3069
+ //#endregion
3070
+ //#region src/generators/cm34-mission-tasks/neutralize.ts
3071
+ const DEFAULT_NEUTRALIZE_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3072
+ const NEUTRALIZE_METADATA = {
3073
+ id: "neutralize",
3074
+ name: "Neutralize",
3075
+ description: "Renders enemy personnel or materiel incapable of interfering with an operation.",
3076
+ entity: "Mission Tasks",
3077
+ entityType: "Neutralize",
3078
+ value: "341600",
3079
+ minCoordinates: 1,
3080
+ maxCoordinates: 1,
3081
+ geometry: "point",
3082
+ geometryTypes: ["MultiLineString", "Point"],
3083
+ paints: {
3084
+ stroke: true,
3085
+ fill: "none",
3086
+ text: true
3087
+ },
3088
+ drawRule: "Point2",
3089
+ uniformSizing: true,
3090
+ labelGeometryUsesResolvedMetrics: true,
3091
+ params: CROSS_TASK_PARAMS
3092
+ };
3093
+ /** Generates the Neutralize mission task (341600) around its sole center anchor. */
3094
+ function createNeutralizeSymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3095
+ return createCrossTaskSymbol(coordinates, options, "neutralize", context);
3096
+ }
3097
+ const NEUTRALIZE = defineControlMeasure({
3098
+ metadata: NEUTRALIZE_METADATA,
3099
+ generator: createNeutralizeSymbol,
3100
+ defaultOptions: DEFAULT_NEUTRALIZE_OPTIONS,
3101
+ rule: staticPointDrawRule,
3102
+ transformOptions: transformCrossTaskOptions,
3103
+ previewSample: {
3104
+ controlPoints: [[0, 0]],
3105
+ options: { sizeMeters: 1e3 }
3106
+ }
3107
+ });
3108
+ //#endregion
3109
+ //#region src/generators/cm34-mission-tasks/destroy.ts
3110
+ const DEFAULT_DESTROY_OPTIONS = DEFAULT_CROSS_TASK_OPTIONS;
3111
+ const DESTROY_METADATA = {
3112
+ id: "destroy",
3113
+ name: "Destroy",
3114
+ description: "Renders an enemy force or capability physically combat-ineffective until reconstituted.",
3115
+ entity: "Mission Tasks",
3116
+ entityType: "Destroy",
3117
+ value: "340900",
3118
+ minCoordinates: 1,
3119
+ maxCoordinates: 1,
3120
+ geometry: "point",
3121
+ geometryTypes: ["MultiLineString", "Point"],
3122
+ paints: {
3123
+ stroke: true,
3124
+ fill: "none",
3125
+ text: true
3126
+ },
3127
+ drawRule: "Point2",
3128
+ uniformSizing: true,
3129
+ labelGeometryUsesResolvedMetrics: true,
3130
+ params: CROSS_TASK_PARAMS
3131
+ };
3132
+ /** Generates the Destroy mission task (340900) around its sole center anchor. */
3133
+ function createDestroySymbol(coordinates, options = {}, _textAmplifiers = {}, context = {}) {
3134
+ return createCrossTaskSymbol(coordinates, options, "destroy", context);
3135
+ }
3136
+ const DESTROY = defineControlMeasure({
3137
+ metadata: DESTROY_METADATA,
3138
+ generator: createDestroySymbol,
3139
+ defaultOptions: DEFAULT_DESTROY_OPTIONS,
3140
+ rule: staticPointDrawRule,
3141
+ transformOptions: transformCrossTaskOptions,
3142
+ previewSample: {
3143
+ controlPoints: [[0, 0]],
3144
+ options: { sizeMeters: 1e3 }
3145
+ }
3146
+ });
3147
+ //#endregion
3148
+ //#region src/internal/echelon.ts
3149
+ /**
3150
+ * APP-6 echelon (Field B) glyph vocabulary, shared by the control measures that
3151
+ * stamp a unit-size amplifier onto their geometry (Boundary, Battle Position).
3152
+ *
3153
+ * A compact port of the APP-6 echelon designs, modeled on milsymbol but
3154
+ * expressed as parametric geometry so it bakes into GeoJSON and strokes
3155
+ * monocolor (ADR-0023). Every glyph is built from three primitives — vertical
3156
+ * bars, crosses, and dots — plus the team/crew ring and command `++`.
3157
+ */
3158
+ const ECHELONS = {
3159
+ team: {
3160
+ kind: "ring",
3161
+ count: 1,
3162
+ label: "Team/Crew"
3163
+ },
3164
+ crew: {
3165
+ kind: "ring",
3166
+ count: 1
3167
+ },
3168
+ squad: {
3169
+ kind: "dots",
3170
+ count: 1,
3171
+ label: "Squad"
3172
+ },
3173
+ section: {
3174
+ kind: "dots",
3175
+ count: 2,
3176
+ label: "Section"
3177
+ },
3178
+ platoon: {
3179
+ kind: "dots",
3180
+ count: 3,
3181
+ label: "Platoon"
3182
+ },
3183
+ company: {
3184
+ kind: "bars",
3185
+ count: 1,
3186
+ label: "Company"
3187
+ },
3188
+ battalion: {
3189
+ kind: "bars",
3190
+ count: 2,
3191
+ label: "Battalion"
3192
+ },
3193
+ regiment: {
3194
+ kind: "bars",
3195
+ count: 3,
3196
+ label: "Regiment"
3197
+ },
3198
+ brigade: {
3199
+ kind: "crosses",
3200
+ count: 1,
3201
+ label: "Brigade"
3202
+ },
3203
+ division: {
3204
+ kind: "crosses",
3205
+ count: 2,
3206
+ label: "Division"
3207
+ },
3208
+ corps: {
3209
+ kind: "crosses",
3210
+ count: 3,
3211
+ label: "Corps"
3212
+ },
3213
+ army: {
3214
+ kind: "crosses",
3215
+ count: 4,
3216
+ label: "Army"
3217
+ },
3218
+ "army-group": {
3219
+ kind: "crosses",
3220
+ count: 5,
3221
+ label: "Army Group"
3222
+ },
3223
+ region: {
3224
+ kind: "crosses",
3225
+ count: 6,
3226
+ label: "Region"
3227
+ },
3228
+ command: {
3229
+ kind: "command",
3230
+ count: 2,
3231
+ label: "Command"
3232
+ }
3233
+ };
3234
+ /** Symbol-form aliases (`"XX"`, `"II"`, `"•••"`, …) → canonical echelon key. */
3235
+ const ECHELON_ALIASES = {
3236
+ ø: "team",
3237
+ "•": "squad",
3238
+ "••": "section",
3239
+ "•••": "platoon",
3240
+ i: "company",
3241
+ ii: "battalion",
3242
+ iii: "regiment",
3243
+ x: "brigade",
3244
+ xx: "division",
3245
+ xxx: "corps",
3246
+ xxxx: "army",
3247
+ xxxxx: "army-group",
3248
+ xxxxxx: "region",
3249
+ "++": "command"
3250
+ };
3251
+ /** Enum options for the params panel, derived from {@link ECHELONS}. */
3252
+ const ECHELON_PARAM_OPTIONS = [{
3253
+ label: "None",
3254
+ value: "none"
3255
+ }, ...Object.entries(ECHELONS).filter(([, spec]) => spec.label !== void 0).map(([value, spec]) => ({
3256
+ label: spec.label,
3257
+ value
3258
+ }))];
3259
+ /**
3260
+ * Effective echelon glyph height in meters. Screen-anchored when a pixel size
3261
+ * is supplied with a live resolution; ground-anchored (meters) otherwise.
3262
+ * Clamped strictly positive.
3263
+ */
3264
+ function resolveEchelonHeight(echelonSize, echelonSizePixels, metersPerPixel) {
3265
+ let h = echelonSize;
3266
+ if (echelonSizePixels !== void 0 && metersPerPixel !== void 0 && metersPerPixel > 0) h = echelonSizePixels * metersPerPixel;
3267
+ return Math.max(EPSILON, h);
3268
+ }
3269
+ function resolveEchelon(echelon) {
3270
+ const key = echelon.trim().toLowerCase();
3271
+ if (key.length === 0) return void 0;
3272
+ const alias = ECHELON_ALIASES[key];
3273
+ return ECHELONS[key] ?? (alias ? ECHELONS[alias] : void 0);
3274
+ }
3275
+ /**
3276
+ * Builds the echelon glyph centered at `center`, with its baseline along
3277
+ * `along` and `perp` (both unit vectors), `h` meters tall. Returns the glyph's
3278
+ * `strokes` (open polylines), `fills` (closed rings drawn as solid shapes —
3279
+ * the squad/section/platoon dots), and its intrinsic width along the line; the
3280
+ * caller adds the configurable side padding to derive the line gap.
3281
+ */
3282
+ function buildEchelonGlyph(center, along, perp, h, spec) {
3283
+ const P = (a, p) => {
3284
+ const q = vecAdd(vecAdd(center, vecScale(along, a)), vecScale(perp, p));
3285
+ return unproject(q[0], q[1]);
3286
+ };
3287
+ const ring = (a, r) => {
3288
+ const pts = [];
3289
+ const steps = 16;
3290
+ for (let i = 0; i <= steps; i++) {
3291
+ const t = 2 * Math.PI * i / steps;
3292
+ pts.push(P(a + r * Math.cos(t), r * Math.sin(t)));
3293
+ }
3294
+ return pts;
3295
+ };
3296
+ const strokes = [];
3297
+ const fills = [];
3298
+ const half = h / 2;
3299
+ switch (spec.kind) {
3300
+ case "bars": {
3301
+ const spacing = h * .8;
3302
+ const total = (spec.count - 1) * spacing;
3303
+ for (let j = 0; j < spec.count; j++) {
3304
+ const a = -total / 2 + j * spacing;
3305
+ strokes.push([P(a, -half), P(a, half)]);
3306
+ }
3307
+ return {
3308
+ strokes,
3309
+ fills,
3310
+ width: total
3311
+ };
3312
+ }
3313
+ case "crosses": {
3314
+ const w = h;
3315
+ const spacing = h * 1.4;
3316
+ const total = (spec.count - 1) * spacing;
3317
+ for (let j = 0; j < spec.count; j++) {
3318
+ const a0 = -total / 2 + j * spacing;
3319
+ strokes.push([P(a0 - w / 2, -half), P(a0 + w / 2, half)]);
3320
+ strokes.push([P(a0 - w / 2, half), P(a0 + w / 2, -half)]);
3321
+ }
3322
+ return {
3323
+ strokes,
3324
+ fills,
3325
+ width: total + w
3326
+ };
3327
+ }
3328
+ case "dots": {
3329
+ const r = h * .3;
3330
+ const spacing = h * 1.2;
3331
+ const total = (spec.count - 1) * spacing;
3332
+ for (let j = 0; j < spec.count; j++) fills.push(ring(-total / 2 + j * spacing, r));
3333
+ return {
3334
+ strokes,
3335
+ fills,
3336
+ width: total + 2 * r
3337
+ };
3338
+ }
3339
+ case "ring": {
3340
+ const r = h * .6;
3341
+ strokes.push(ring(0, r));
3342
+ strokes.push([P(-h * .8, -h * .4), P(h * .8, h * .4)]);
3343
+ return {
3344
+ strokes,
3345
+ fills,
3346
+ width: h * 1.6
3347
+ };
3348
+ }
3349
+ case "command": {
3350
+ const arm = h * .5;
3351
+ const spacing = h * 1.4;
3352
+ const total = (spec.count - 1) * spacing;
3353
+ for (let j = 0; j < spec.count; j++) {
3354
+ const a0 = -total / 2 + j * spacing;
3355
+ strokes.push([P(a0 - arm, 0), P(a0 + arm, 0)]);
3356
+ strokes.push([P(a0, -half), P(a0, half)]);
3357
+ }
3358
+ return {
3359
+ strokes,
3360
+ fills,
3361
+ width: total + 2 * arm
3362
+ };
3363
+ }
3364
+ }
3365
+ }
3366
+ //#endregion
3367
+ //#region src/generators/cm15-maneuver-areas/params.ts
3368
+ const AREA_SMOOTH_PARAMS = [{
3369
+ key: "smooth",
3370
+ label: "Smooth",
3371
+ description: "Round the area's corners by curving the boundary through the control points.",
3372
+ type: "boolean"
3373
+ }, {
3374
+ key: "smoothResolution",
3375
+ presentationTier: "advanced",
3376
+ label: "Smooth resolution",
3377
+ description: "Number of samples per segment when smooth mode is enabled.",
3378
+ type: "number",
3379
+ min: 2,
3380
+ max: 64,
3381
+ step: 1,
3382
+ visibleWhen: (opts) => Boolean(opts.smooth)
3383
+ }];
3384
+ const ATTACK_SHAFT_PARAMS = [
3385
+ {
3386
+ key: "shaftWidthRatio",
3387
+ presentationTier: "advanced",
3388
+ label: "Shaft width",
3389
+ description: "Arrow shaft width as a ratio of the arrowhead width",
3390
+ type: "number",
3391
+ min: .1,
3392
+ max: 1,
3393
+ step: .05
3394
+ },
3395
+ {
3396
+ key: "rearWidthRatio",
3397
+ presentationTier: "advanced",
3398
+ label: "Rear width",
3399
+ description: "Width at the rear of the shaft as a ratio of the arrowhead width.",
3400
+ type: "number",
3401
+ min: .1,
3402
+ max: 2,
3403
+ step: .05
3404
+ },
3405
+ {
3406
+ key: "smooth",
3407
+ label: "Smooth",
3408
+ description: "Round the corners where the shaft changes direction.",
3409
+ type: "boolean"
3410
+ },
3411
+ {
3412
+ key: "smoothResolution",
3413
+ presentationTier: "advanced",
3414
+ label: "Smooth resolution",
3415
+ description: "Number of segments approximating each rounded bend when smooth mode is enabled.",
3416
+ type: "number",
3417
+ min: 1,
3418
+ max: 20,
3419
+ step: 1,
3420
+ visibleWhen: (opts) => opts.smooth === true
3421
+ }
3422
+ ];
3423
+ const FIRE_ARROW_PARAMS = [
3424
+ {
3425
+ key: "arrowheadWidthRatio",
3426
+ presentationTier: "advanced",
3427
+ label: "Arrowhead width",
3428
+ description: "Arrowhead width as a ratio of the shaft length",
3429
+ type: "number",
3430
+ min: .05,
3431
+ max: 1,
3432
+ step: .01
3433
+ },
3434
+ {
3435
+ key: "rearLineLengthRatio",
3436
+ presentationTier: "advanced",
3437
+ label: "Rear line length",
3438
+ description: "Length of the rear flare lines as a ratio of the head",
3439
+ type: "number",
3440
+ min: .05,
3441
+ max: 1,
3442
+ step: .01
3443
+ },
3444
+ {
3445
+ key: "rearLineAngle",
3446
+ presentationTier: "advanced",
3447
+ label: "Rear line angle",
3448
+ description: "Angle of the rear flare lines, in degrees",
3449
+ type: "number",
3450
+ min: 0,
3451
+ max: 90,
3452
+ step: 1
3453
+ }
3454
+ ];
3455
+ const FORTIFIED_AREA_SIZE_PARAMS = [{
3456
+ key: "toothSizePixels",
3457
+ label: "Tooth size",
3458
+ description: "Size of the fortified-area teeth, in pixels",
3459
+ type: "number",
3460
+ min: 5,
3461
+ max: 50,
3462
+ step: 1,
3463
+ unit: "px"
3464
+ }, {
3465
+ key: "toothSize",
3466
+ label: "Tooth size",
3467
+ description: "Size of the fortified-area teeth, in meters",
3468
+ type: "number",
3469
+ min: 1,
3470
+ max: 500,
3471
+ step: 1,
3472
+ unit: "m"
3473
+ }];
3474
+ const ENCIRCLEMENT_PARAMS = [
3475
+ {
3476
+ key: "smooth",
3477
+ label: "Smooth",
3478
+ description: "Round the boundary by curving it through the control points.",
3479
+ type: "boolean"
3480
+ },
3481
+ {
3482
+ key: "smoothResolution",
3483
+ presentationTier: "advanced",
3484
+ label: "Smooth resolution",
3485
+ description: "Number of samples per segment when smooth mode is enabled.",
3486
+ type: "number",
3487
+ min: 2,
3488
+ max: 64,
3489
+ step: 1,
3490
+ visibleWhen: (opts) => Boolean(opts.smooth)
3491
+ },
3492
+ {
3493
+ key: "barbSpacingRatio",
3494
+ presentationTier: "advanced",
3495
+ label: "Spacing",
3496
+ description: "Spacing between barbs as a ratio of the symbol radius (smaller is denser).",
3497
+ type: "number",
3498
+ min: .15,
3499
+ max: 1.5,
3500
+ step: .05
3501
+ },
3502
+ {
3503
+ key: "barbLengthRatio",
3504
+ presentationTier: "advanced",
3505
+ label: "Barb length",
3506
+ description: "Barb height as a ratio of the symbol radius (amplitude).",
3507
+ type: "number",
3508
+ min: .02,
3509
+ max: .5,
3510
+ step: .01
3511
+ }
3512
+ ];
3513
+ const FORTIFIED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, ...FORTIFIED_AREA_SIZE_PARAMS];
3514
+ const AREA_LABEL_PADDING_PARAM = {
3515
+ key: "labelPadding",
3516
+ presentationTier: "advanced",
3517
+ label: "Label padding",
3518
+ description: "Extra spacing between or around the area's labels, as a ratio of label height",
3519
+ type: "number",
3520
+ min: 0,
3521
+ max: 2,
3522
+ step: .05
3523
+ };
3524
+ const MULTILINE_AREA_LABEL_PARAM = {
3525
+ key: "multilineLabel",
3526
+ label: "Multiline label",
3527
+ description: "Place the doctrinal label above Field T on separate centered lines",
3528
+ type: "boolean"
3529
+ };
3530
+ const LABELED_AREA_PARAMS = [...AREA_SMOOTH_PARAMS, AREA_LABEL_PADDING_PARAM];
3531
+ const PREFIXED_LABELED_AREA_PARAMS = [
3532
+ ...AREA_SMOOTH_PARAMS,
3533
+ MULTILINE_AREA_LABEL_PARAM,
3534
+ AREA_LABEL_PADDING_PARAM
3535
+ ];
3536
+ const ECHELON_GLYPH_PARAMS = [
3537
+ {
3538
+ key: "echelon",
3539
+ label: "Echelon",
3540
+ description: "Field B unit-size amplifier, drawn on the boundary.",
3541
+ type: "enum",
3542
+ presentationTier: "primary",
3543
+ semanticRole: "doctrinal",
3544
+ options: ECHELON_PARAM_OPTIONS
3545
+ },
3546
+ {
3547
+ key: "echelonSizePixels",
3548
+ label: "Echelon size",
3549
+ description: "Echelon glyph height in screen pixels.",
3550
+ type: "number",
3551
+ min: 8,
3552
+ max: 48,
3553
+ step: 1,
3554
+ unit: "px"
3555
+ },
3556
+ {
3557
+ key: "echelonSize",
3558
+ label: "Echelon size",
3559
+ description: "Echelon glyph height in meters.",
3560
+ type: "number",
3561
+ min: 50,
3562
+ max: 2e4,
3563
+ step: 50,
3564
+ unit: "m"
3565
+ },
3566
+ {
3567
+ key: "echelonPadding",
3568
+ presentationTier: "advanced",
3569
+ label: "Echelon padding",
3570
+ description: "Gap on each side of the echelon, as a ratio of its height.",
3571
+ type: "number",
3572
+ min: 0,
3573
+ max: 2,
3574
+ step: .05
3575
+ },
3576
+ {
3577
+ key: "echelonPosition",
3578
+ presentationTier: "advanced",
3579
+ label: "Echelon position",
3580
+ description: "Slide the echelon along the boundary, as a fraction of the perimeter from the bottom edge.",
3581
+ type: "number",
3582
+ min: -.5,
3583
+ max: .5,
3584
+ step: .01
3585
+ }
3586
+ ];
3587
+ const ECHELON_AREA_PARAMS = [
3588
+ ...AREA_SMOOTH_PARAMS,
3589
+ ...ECHELON_GLYPH_PARAMS,
3590
+ AREA_LABEL_PADDING_PARAM
3591
+ ];
3592
+ const PREFIXED_ECHELON_AREA_PARAMS = [
3593
+ ...AREA_SMOOTH_PARAMS,
3594
+ MULTILINE_AREA_LABEL_PARAM,
3595
+ ...ECHELON_GLYPH_PARAMS,
3596
+ AREA_LABEL_PADDING_PARAM
3597
+ ];
3598
+ const AREA_DESIGNATION_AMPLIFIER = {
3599
+ key: "T",
3600
+ label: "Unique designation",
3601
+ description: "Field T — the unit's designation, centered in the area.",
3602
+ maxLength: 21
3603
+ };
3604
+ const AREA_HOSTILE_AMPLIFIER = {
3210
3605
  key: "N",
3211
3606
  label: "Hostile (ENY) marker",
3212
- description: "Field N — literal \"ENY\" marker beyond both ends of the line.",
3607
+ description: "Field N — literal \"ENY\" marker at the area's west and east edges.",
3213
3608
  placeholder: "ENY",
3214
3609
  maxLength: 3
3215
3610
  };
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
3611
+ const AREA_TEXT_AMPLIFIERS = [
3612
+ {
3613
+ key: "T",
3614
+ label: "Unique designation",
3615
+ description: "Field T — the area's unique designation.",
3616
+ maxLength: 21
3617
+ },
3618
+ {
3619
+ key: "H",
3620
+ label: "Additional information",
3621
+ description: "Field H — additional free-text information, shown below the designation.",
3622
+ maxLength: 20
3623
+ },
3624
+ {
3625
+ key: "W",
3626
+ label: "Effective from (DTG)",
3627
+ description: "Field W — date-time group the area becomes effective.",
3628
+ placeholder: "051030Z",
3629
+ maxLength: 16
3630
+ },
3631
+ {
3632
+ key: "W1",
3633
+ label: "Effective to (DTG)",
3634
+ description: "Field W1 — date-time group the area ceases to be effective.",
3635
+ maxLength: 16
3636
+ },
3637
+ AREA_HOSTILE_AMPLIFIER
3638
+ ];
3639
+ const AREA_UNIT_DESIGNATION_HOSTILE_AMPLIFIERS = [AREA_DESIGNATION_AMPLIFIER, AREA_HOSTILE_AMPLIFIER];
3640
+ /** {@link AREA_TEXT_AMPLIFIERS} without the H (additional information) field,
3641
+ * for measures whose template omits that row but keeps the W/W1 window and
3642
+ * the N (hostile) marker (Submarine Action Area, Submarine-Generated Action
3643
+ * Area). */
3644
+ const AREA_TEXT_AMPLIFIERS_NO_ADDITIONAL_INFO = AREA_TEXT_AMPLIFIERS.filter((d) => d.key !== "H");
3645
+ /** {@link AREA_TEXT_AMPLIFIERS} reduced to just the designation (Field T) and
3646
+ * hostile marker (Field N), for measures whose template carries no
3647
+ * additional-information or date-time-group rows (Area, Drop Zone,
3648
+ * Extraction Zone). */
3649
+ const AREA_TEXT_AMPLIFIERS_DESIGNATION_HOSTILE = AREA_TEXT_AMPLIFIERS.filter((d) => d.key === "T" || d.key === "N");
3650
+ //#endregion
3651
+ //#region src/generators/cm15-maneuver-areas/airborneAttack.ts
3652
+ const DEFAULT_AIRBORNE_ATTACK_OPTIONS = {
3653
+ shaftWidthRatio: DEFAULT_SHAFT_WIDTH_RATIO$1,
3654
+ rearWidthRatio: DEFAULT_REAR_WIDTH_RATIO,
3655
+ smooth: false,
3656
+ smoothResolution: 5
3657
+ };
3658
+ const AIRBORNE_ATTACK_METADATA = {
3659
+ id: "airborne-attack",
3660
+ name: "Airborne Attack",
3661
+ description: "Airborne or aviation axis of advance.",
3662
+ entity: "Maneuver Areas",
3663
+ entityType: "Axis of Advance",
3664
+ entitySubtype: "Airborne Attack",
3665
+ value: "151401",
3666
+ minCoordinates: 3,
3667
+ geometry: "line",
3668
+ geometryTypes: ["LineString"],
3669
+ paints: {
3670
+ stroke: true,
3671
+ fill: "none",
3672
+ text: false
3673
+ },
3674
+ drawRule: "Axis1",
3675
+ params: ATTACK_SHAFT_PARAMS
3226
3676
  };
3227
3677
  /**
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.
3678
+ * Generates a GeoJSON FeatureCollection for an Airborne Attack tactical symbol.
3290
3679
  *
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.
3680
+ * The symbol is similar to Supporting Attack but features a crossover point
3681
+ * between the shaft and the head (Points 1 and 2/Neck).
3682
+ *
3683
+ * @param coordinates - An array of GeoJSON Positions.
3684
+ * @param options - Configuration options.
3685
+ * @returns A GeoJSON FeatureCollection<LineString>.
3322
3686
  */
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
3687
+ function createAirborneAttack(coordinates, options = {}) {
3688
+ const { geometry } = processAttackGeometry(coordinates, options);
3689
+ if (!geometry) return {
3690
+ type: "FeatureCollection",
3691
+ features: []
3355
3692
  };
3693
+ const shaftLeftToNeck = geometry.shaftLeft.slice(0, -1);
3694
+ const shaftRightToNeck = geometry.shaftRight.slice(0, -1);
3356
3695
  return {
3357
- width: measured.widthCssPixels * constructionScale,
3358
- height: measured.heightCssPixels * constructionScale
3696
+ type: "FeatureCollection",
3697
+ features: [{
3698
+ type: "Feature",
3699
+ properties: {},
3700
+ geometry: {
3701
+ type: "LineString",
3702
+ coordinates: [
3703
+ ...shaftLeftToNeck,
3704
+ geometry.headRing[3],
3705
+ geometry.headRing[2],
3706
+ geometry.headRing[1],
3707
+ geometry.headRing[0],
3708
+ geometry.headRing[5],
3709
+ ...[...shaftRightToNeck].reverse()
3710
+ ].map((p) => unproject(p[0], p[1]))
3711
+ }
3712
+ }]
3359
3713
  };
3360
3714
  }
3715
+ const AIRBORNE_ATTACK = defineControlMeasure({
3716
+ metadata: AIRBORNE_ATTACK_METADATA,
3717
+ generator: createAirborneAttack,
3718
+ defaultOptions: DEFAULT_AIRBORNE_ATTACK_OPTIONS,
3719
+ rule: axis1DrawRule,
3720
+ optionHandles: variableWidthAttackOptionHandles
3721
+ });
3361
3722
  /**
3362
- * Half-length of the knockout required for a label along `direction`.
3723
+ * Reacts to an **input-contract** violation according to `mode`: `throw`
3724
+ * raises, `warn` logs, `silent` (the default) does neither. Always returns
3725
+ * `false` so a caller can `return reportValidationIssue(...)` from a guard.
3363
3726
  *
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.
3727
+ * Governs the *input contract* only (control-point count and finiteness),
3728
+ * checked once at the render seam — not geometric degeneracy, which always
3729
+ * yields an empty render silently. See ADR-0014 and the `CONTEXT.md` terms.
3367
3730
  */
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;
3731
+ function reportValidationIssue(mode, message) {
3732
+ const resolved = mode ?? "silent";
3733
+ if (resolved === "throw") throw new Error(message);
3734
+ if (resolved === "warn") console.warn(message);
3735
+ return false;
3372
3736
  }
3373
3737
  /**
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.
3738
+ * Narrows a control-point array to the fixed `[PT1, PT2, PT3]` tuple the ambush
3739
+ * and search-area generators require. The render seam has already validated
3740
+ * that at least three valid points are present (ADR-0014), so this is a pure
3741
+ * cast with no runtime guard. Used by those measures' definition adapters so
3742
+ * the generators keep their precise tuple parameter (ADR-0013).
3377
3743
  */
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;
3744
+ function asThreePoints(points) {
3745
+ return [
3746
+ points[0],
3747
+ points[1],
3748
+ points[2]
3749
+ ];
3387
3750
  }
3751
+ const DEFAULT_AMBUSH_OPTIONS = {
3752
+ arrowheadLengthRatio: .15,
3753
+ arrowheadWidthRatio: .5,
3754
+ hatchLengthRatio: .15,
3755
+ hatchCount: 5,
3756
+ curveDepthRatio: .25,
3757
+ arcSegments: 30
3758
+ };
3759
+ const AMBUSH_METADATA = {
3760
+ id: "ambush",
3761
+ name: "Ambush",
3762
+ description: "A variation of attack from concealed positions against a moving or temporarily halted enemy.",
3763
+ entity: "Maneuver Lines",
3764
+ entityType: "Ambush",
3765
+ value: "141700",
3766
+ minCoordinates: 3,
3767
+ maxCoordinates: 3,
3768
+ geometry: "line",
3769
+ geometryTypes: ["MultiLineString"],
3770
+ paints: {
3771
+ stroke: true,
3772
+ fill: "none",
3773
+ text: false
3774
+ },
3775
+ drawRule: "Line29",
3776
+ params: [
3777
+ {
3778
+ key: "arrowheadLengthRatio",
3779
+ presentationTier: "advanced",
3780
+ label: "Arrowhead length",
3781
+ description: "Arrowhead length as a ratio of the chord span",
3782
+ type: "number",
3783
+ min: .01,
3784
+ max: .5,
3785
+ step: .01
3786
+ },
3787
+ {
3788
+ key: "arrowheadWidthRatio",
3789
+ presentationTier: "advanced",
3790
+ label: "Arrowhead width",
3791
+ description: "Arrowhead base width as a multiple of the arrowhead length",
3792
+ type: "number",
3793
+ min: .05,
3794
+ max: 2,
3795
+ step: .01
3796
+ },
3797
+ {
3798
+ key: "hatchLengthRatio",
3799
+ presentationTier: "advanced",
3800
+ label: "Hatch length",
3801
+ description: "Length of the parallel hatches as a fraction of the chord span",
3802
+ type: "number",
3803
+ min: .01,
3804
+ max: .5,
3805
+ step: .01
3806
+ },
3807
+ {
3808
+ key: "hatchCount",
3809
+ presentationTier: "advanced",
3810
+ label: "Hatch count",
3811
+ description: "Number of parallel hatches spaced along the curve",
3812
+ type: "number",
3813
+ min: 0,
3814
+ max: 20,
3815
+ step: 1
3816
+ },
3817
+ {
3818
+ key: "curveDepthRatio",
3819
+ presentationTier: "advanced",
3820
+ label: "Curve depth",
3821
+ description: "How far the back line bends toward the tip, as a fraction of the chord span",
3822
+ type: "number",
3823
+ min: 0,
3824
+ max: 1,
3825
+ step: .01
3826
+ },
3827
+ {
3828
+ key: "arcSegments",
3829
+ label: "Arc segments",
3830
+ description: "Number of segments used to render the curve",
3831
+ type: "number",
3832
+ presentationTier: "advanced",
3833
+ min: 4,
3834
+ max: 96,
3835
+ step: 1
3836
+ }
3837
+ ]
3838
+ };
3839
+ const quadBezier = (t, a, b, c) => {
3840
+ const it = 1 - t;
3841
+ 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]];
3842
+ };
3388
3843
  /**
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.
3844
+ * Generates a GeoJSON FeatureCollection for an AMBUSH tactical symbol.
3845
+ * Handles internal projection to maintain strict perpendicularity and parallel hatching.
3846
+ *
3847
+ * @param coordinates - [PT 1 (Target Tip), PT 2 (Back Chord End), PT 3 (Back Chord End)]
3848
+ * @param options - Geometric and styling parameters
3849
+ * @returns GeoJSON FeatureCollection containing LineString components
3393
3850
  */
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}`);
3851
+ function createAmbushSymbol(coordinates, options = {}) {
3852
+ const { arrowheadLengthRatio, arrowheadWidthRatio, hatchLengthRatio, hatchCount, curveDepthRatio, arcSegments } = {
3853
+ ...DEFAULT_AMBUSH_OPTIONS,
3854
+ ...options
3855
+ };
3856
+ const safeArcSegments = Math.max(1, Math.floor(arcSegments));
3857
+ const safeHatchCount = Math.max(0, Math.floor(hatchCount));
3858
+ const safeArrowheadLengthRatio = Math.max(0, arrowheadLengthRatio);
3859
+ const safeArrowheadWidthRatio = Math.max(0, arrowheadWidthRatio);
3860
+ const safeHatchLengthRatio = Math.max(0, hatchLengthRatio);
3861
+ const safeCurveDepthRatio = Math.max(0, curveDepthRatio);
3862
+ const [p1, p2, p3] = coordinates.map((coord) => project(coord[0], coord[1]));
3863
+ const mid = vecScale(vecAdd(p2, p3), .5);
3864
+ const v23 = vecSub(p3, p2);
3865
+ const span = vecMag(v23);
3866
+ if (span < 1e-6) return {
3867
+ type: "FeatureCollection",
3868
+ features: []
3869
+ };
3870
+ const dir23 = vecNorm(v23);
3871
+ const n = [-dir23[1], dir23[0]];
3872
+ const dist = vecDot(vecSub(p1, mid), n);
3873
+ const side = dist >= 0 ? 1 : -1;
3874
+ const ctrl = vecAdd(mid, vecScale(n, side * span * safeCurveDepthRatio));
3875
+ const shaftOrigin = quadBezier(.5, p2, ctrl, p3);
3876
+ const adjP1 = vecAdd(shaftOrigin, vecScale(n, side * Math.max(0, Math.abs(dist) - span * safeCurveDepthRatio * .5)));
3877
+ const lines = [];
3878
+ lines.push([shaftOrigin, adjP1]);
3879
+ const hL = span * safeArrowheadLengthRatio;
3880
+ const hw = hL * safeArrowheadWidthRatio;
3881
+ const hBase = vecSub(adjP1, vecScale(n, side * hL));
3882
+ const perp = [-n[1], n[0]];
3883
+ lines.push([
3884
+ vecAdd(hBase, vecScale(perp, hw)),
3885
+ adjP1,
3886
+ vecSub(hBase, vecScale(perp, hw))
3887
+ ]);
3888
+ const curve = [];
3889
+ for (let i = 0; i <= safeArcSegments; i++) {
3890
+ const t = i / safeArcSegments;
3891
+ curve.push(quadBezier(t, p2, ctrl, p3));
3406
3892
  }
3407
- }
3408
- /**
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.
3417
- */
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
- };
3893
+ lines.push(curve);
3894
+ const hatchLengthActual = span * safeHatchLengthRatio;
3895
+ for (let i = 1; i <= safeHatchCount; i++) {
3896
+ const pt = quadBezier(i / (safeHatchCount + 1), p2, ctrl, p3);
3897
+ lines.push([pt, vecSub(pt, vecScale(n, side * hatchLengthActual))]);
3433
3898
  }
3434
- return frames;
3899
+ return {
3900
+ type: "FeatureCollection",
3901
+ features: [{
3902
+ type: "Feature",
3903
+ properties: {},
3904
+ geometry: {
3905
+ type: "MultiLineString",
3906
+ coordinates: lines.map((line) => line.map((c) => unproject(c[0], c[1])))
3907
+ }
3908
+ }]
3909
+ };
3435
3910
  }
3911
+ const AMBUSH = defineControlMeasure({
3912
+ metadata: AMBUSH_METADATA,
3913
+ generator: (controlPoints, options) => createAmbushSymbol(asThreePoints(controlPoints), options),
3914
+ defaultOptions: DEFAULT_AMBUSH_OPTIONS,
3915
+ rule: ambushDrawRule
3916
+ });
3917
+ //#endregion
3918
+ //#region src/internal/gapped-line.ts
3436
3919
  /**
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.
3920
+ * Pushes the arc-length gap `[g0, g1]` onto `gaps`, normalizing it onto
3921
+ * `[0, total)` and splitting it into two intervals when it straddles a ring
3922
+ * seam ({@link buildGappedLine} walks a flat array and does not wrap on its
3923
+ * own). Use this for closed rings; open polylines can push {@link ArcGap}s
3924
+ * directly.
3445
3925
  */
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);
3926
+ function pushWrappedGap(gaps, g0, g1, total) {
3927
+ if (g1 - g0 >= total) {
3928
+ gaps.push({
3929
+ g0: 0,
3930
+ g1: total
3931
+ });
3932
+ return;
3933
+ }
3934
+ const lo = (g0 % total + total) % total;
3935
+ const hi = (g1 % total + total) % total;
3936
+ if (lo <= hi) gaps.push({
3937
+ g0: lo,
3938
+ g1: hi
3939
+ });
3940
+ else {
3941
+ gaps.push({
3942
+ g0: lo,
3943
+ g1: total
3944
+ });
3945
+ gaps.push({
3946
+ g0: 0,
3947
+ g1: hi
3948
+ });
3455
3949
  }
3456
3950
  }
3951
+ /** Whether the global arc position `arc` falls inside any of `gaps` (each
3952
+ * already normalized onto `[0, total)` by {@link pushWrappedGap}, so a plain
3953
+ * per-gap interval test suffices). Shared by the boundary-decoration measures
3954
+ * that suppress a glyph/tic/barb sitting on top of a masking gap. */
3955
+ function arcInGaps(arc, gaps) {
3956
+ return gaps.some((g) => arc >= g.g0 && arc <= g.g1);
3957
+ }
3958
+ /** Appends `position` unless it coincides with the buffer's current tail. */
3959
+ function appendUnique(buffer, position) {
3960
+ const tail = buffer[buffer.length - 1];
3961
+ if (tail && Math.abs(tail[0] - position[0]) < 1e-9 && Math.abs(tail[1] - position[1]) < 1e-9) return;
3962
+ buffer.push(position);
3963
+ }
3457
3964
  /**
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.
3965
+ * Builds the polyline as MultiLineString rings, splitting it with a gap at each
3966
+ * echelon position. `gaps` holds global arc-length intervals (distance from the
3967
+ * start of the whole polyline) to carve out — a single gap can span several
3968
+ * short segments, which matters when the line has been smoothed into many
3969
+ * samples. Works for both open polylines and closed rings (pass a vertex array
3970
+ * whose first and last positions coincide).
3462
3971
  */
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
3972
+ function buildGappedLine(verts, gaps) {
3973
+ const parts = [];
3974
+ let buffer = [];
3975
+ const flush = () => {
3976
+ if (buffer.length >= 2) parts.push(buffer);
3977
+ buffer = [];
3491
3978
  };
3979
+ if (verts.length === 0) return parts;
3980
+ const merged = [];
3981
+ for (const g of [...gaps].sort((x, y) => x.g0 - y.g0)) {
3982
+ const last = merged[merged.length - 1];
3983
+ if (last && g.g0 <= last.g1) last.g1 = Math.max(last.g1, g.g1);
3984
+ else merged.push({ ...g });
3985
+ }
3986
+ appendUnique(buffer, unproject(verts[0][0], verts[0][1]));
3987
+ let dist = 0;
3988
+ for (let i = 0; i < verts.length - 1; i++) {
3989
+ const a = verts[i];
3990
+ const b = verts[i + 1];
3991
+ const seg = vecSub(b, a);
3992
+ const L = vecMag(seg);
3993
+ if (L < 1e-6) continue;
3994
+ const segStart = dist;
3995
+ const segEnd = dist + L;
3996
+ const along = vecNorm(seg);
3997
+ const pointAt = (globalD) => {
3998
+ const q = vecAdd(a, vecScale(along, globalD - segStart));
3999
+ return unproject(q[0], q[1]);
4000
+ };
4001
+ for (const g of merged) {
4002
+ if (g.g1 <= segStart || g.g0 >= segEnd) continue;
4003
+ const g0 = Math.max(segStart, g.g0);
4004
+ const g1 = Math.min(segEnd, g.g1);
4005
+ if (g1 <= g0) continue;
4006
+ appendUnique(buffer, pointAt(g0));
4007
+ flush();
4008
+ buffer.push(pointAt(g1));
4009
+ }
4010
+ appendUnique(buffer, unproject(b[0], b[1]));
4011
+ dist = segEnd;
4012
+ }
4013
+ flush();
4014
+ return parts;
3492
4015
  }
3493
4016
  //#endregion
3494
4017
  //#region src/text-amplifiers.ts
@@ -6317,7 +6840,7 @@ function createAttackByFire(coordinates, options = {}) {
6317
6840
  const p2Back = vecAdd(p2, vecScale(dirBackLeft, backLen));
6318
6841
  const p3Back = vecAdd(p3, vecScale(dirBackRight, backLen));
6319
6842
  const headWidth = lenShaft * arrowheadWidthRatio;
6320
- const headFeatures = createArrowHead$2(pTip, dirShaft, headWidth, headWidth);
6843
+ const headFeatures = createArrowHead$1(pTip, dirShaft, headWidth, headWidth);
6321
6844
  return {
6322
6845
  type: "FeatureCollection",
6323
6846
  features: [
@@ -6360,7 +6883,7 @@ function createAttackByFire(coordinates, options = {}) {
6360
6883
  ]
6361
6884
  };
6362
6885
  }
6363
- function createArrowHead$2(tip, dir, length, width) {
6886
+ function createArrowHead$1(tip, dir, length, width) {
6364
6887
  const norm = [-dir[1], dir[0]];
6365
6888
  const base = vecSub(tip, vecScale(dir, length));
6366
6889
  const left = vecAdd(base, vecScale(norm, width / 2));
@@ -8357,130 +8880,6 @@ const GENERIC_C2_LINE = defineControlMeasure({
8357
8880
  }
8358
8881
  });
8359
8882
  //#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
8883
  //#region src/generators/cm34-mission-tasks/block.ts
8485
8884
  const DEFAULT_STEM_RATIO = 1.5;
8486
8885
  const DEFAULT_BLOCK_MISSION_TASK_OPTIONS = { labelPadding: DEFAULT_LABEL_KNOCKOUT_PADDING };
@@ -8721,7 +9120,7 @@ function createBypassSymbol(coordinates, options = {}, _textAmplifiers = {}, con
8721
9120
  buildEndCaps: ({ p1, p2, frontLength, rearTop }) => {
8722
9121
  const arrowheadLength = frontLength * arrowheadLengthRatio;
8723
9122
  const arrowDir = vecNorm(vecSub(p1, rearTop));
8724
- return [createArrowHead$1(p1, arrowDir, arrowheadLength, arrowheadLength), createArrowHead$1(p2, arrowDir, arrowheadLength, arrowheadLength)];
9123
+ return [createArrowHead$2(p1, arrowDir, arrowheadLength, arrowheadLength), createArrowHead$2(p2, arrowDir, arrowheadLength, arrowheadLength)];
8725
9124
  }
8726
9125
  }, context);
8727
9126
  }
@@ -10007,7 +10406,7 @@ function createClearSymbol(coordinates, options = {}, _textAmplifiers = {}, cont
10007
10406
  lines.push([unproject(rearEnd[0], rearEnd[1]), unproject(gapRear[0], gapRear[1])]);
10008
10407
  lines.push([unproject(gapTip[0], gapTip[1]), unproject(tip[0], tip[1])]);
10009
10408
  } 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));
10409
+ lines.push(createArrowHead$2(tip, arrowDir, arrowheadLength, arrowheadWidth));
10011
10410
  });
10012
10411
  const mainFeature = {
10013
10412
  type: "Feature",
@@ -10168,7 +10567,7 @@ function createIsolateVariantSymbol(coordinates, options, config, context = {})
10168
10567
  const barbLength = radius * barbLengthRatio;
10169
10568
  const lines = [sampleArc(0, labelSweep - labelHalfGap), sampleArc(labelSweep + labelHalfGap, arcSpan)];
10170
10569
  const tipDir = [Math.sin(endAngle), -Math.cos(endAngle)];
10171
- lines.push(createArrowHead$1(pointOnCircle(endAngle), tipDir, barbLength, barbLength));
10570
+ lines.push(createArrowHead$2(pointOnCircle(endAngle), tipDir, barbLength, barbLength));
10172
10571
  const halfWidth = Math.asin(Math.min(1, barbLength / 2 / radius));
10173
10572
  const tipRadius = Math.max(0, radius - barbLength);
10174
10573
  const steps = Math.max(1, barbCount);
@@ -11176,7 +11575,7 @@ function createDelayTaskSymbol(coordinates, config, options, context) {
11176
11575
  const lines = [
11177
11576
  [unproject(p1[0], p1[1]), unproject(gapStart[0], gapStart[1])],
11178
11577
  [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))
11578
+ createArrowHead$2(p1, vecScale(dirLine, -1), lineLength * (options.arrowheadLengthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadLengthRatio), lineLength * (options.arrowheadWidthRatio ?? DEFAULT_DELAY_OPTIONS.arrowheadWidthRatio))
11180
11579
  ];
11181
11580
  if (diameter >= 1e-6) lines.push(createSemicircleArc(p2, diameterEnd, dirLine, options.arcSegments ?? DEFAULT_DELAY_OPTIONS.arcSegments));
11182
11581
  return {
@@ -11259,8 +11658,8 @@ function createHairpinSymbol(coordinates, config, options = {}) {
11259
11658
  ...upperArm,
11260
11659
  createSemicircleArc(p2, p3, upperDirection, options.arcSegments ?? DEFAULT_DELAY_OPTIONS.arcSegments),
11261
11660
  [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)
11661
+ createArrowHead$2(p1, vecScale(upperDirection, -1), upperLength * arrowheadLengthRatio, upperLength * arrowheadWidthRatio),
11662
+ createArrowHead$2(p3, lowerDirection, lowerLength * arrowheadLengthRatio, lowerLength * arrowheadWidthRatio)
11264
11663
  ]
11265
11664
  }
11266
11665
  }, label]
@@ -11496,12 +11895,12 @@ function createDisruptMissionTaskSymbol(coordinates, options = {}, _textAmplifie
11496
11895
  coordinates: [
11497
11896
  [p1LonLat, p2LonLat],
11498
11897
  [[...p1LonLat], unproject(topTip[0], topTip[1])],
11499
- createArrowHead$1(topTip, arrowDirection, headLength, headWidth),
11898
+ createArrowHead$2(topTip, arrowDirection, headLength, headWidth),
11500
11899
  [unproject(rearEnd[0], rearEnd[1]), unproject(gapStart[0], gapStart[1])],
11501
11900
  [unproject(gapEnd[0], gapEnd[1]), unproject(middleTip[0], middleTip[1])],
11502
- createArrowHead$1(middleTip, arrowDirection, headLength, headWidth),
11901
+ createArrowHead$2(middleTip, arrowDirection, headLength, headWidth),
11503
11902
  [[...p2LonLat], unproject(bottomTip[0], bottomTip[1])],
11504
- createArrowHead$1(bottomTip, arrowDirection, headLength, headWidth)
11903
+ createArrowHead$2(bottomTip, arrowDirection, headLength, headWidth)
11505
11904
  ]
11506
11905
  }
11507
11906
  }, createLabelFeature(labelPoint, "D", labelRotation, {
@@ -11607,7 +12006,7 @@ function createEnvelopment(coordinates, options = {}, _textAmplifiers = {}, cont
11607
12006
  let bulgeRadius = [-startRadius[1], startRadius[0]];
11608
12007
  if (vecDot(vecSub(p4, center), bulgeRadius) < 0) bulgeRadius = vecScale(bulgeRadius, -1);
11609
12008
  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));
12009
+ 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
12010
  return {
11612
12011
  type: "FeatureCollection",
11613
12012
  features: [{
@@ -11676,7 +12075,7 @@ function createExploitation(coordinates, _options = {}) {
11676
12075
  features: []
11677
12076
  };
11678
12077
  const direction = vecNorm(axis);
11679
- const chevron = (tip) => createArrowHead$1(tip, direction, finLength * Math.SQRT1_2, finLength * Math.SQRT2);
12078
+ const chevron = (tip) => createArrowHead$2(tip, direction, finLength * Math.SQRT1_2, finLength * Math.SQRT2);
11680
12079
  const [finLeft, , finRight] = chevron(p2);
11681
12080
  return {
11682
12081
  type: "FeatureCollection",
@@ -12046,7 +12445,7 @@ function createInfiltration(coordinates, options = {}, _textAmplifiers = {}, con
12046
12445
  const gapHalf = Math.min(straightLength * Math.min(labelPosition, 1 - labelPosition), labelKnockoutHalfExtent("IN", along, labelRotation, options, context, DEFAULT_INFILTRATION_OPTIONS.labelPadding));
12047
12446
  const gapStart = vecAdd(labelPoint, vecScale(along, -gapHalf));
12048
12447
  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));
12448
+ 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
12449
  return {
12051
12450
  type: "FeatureCollection",
12052
12451
  features: [{
@@ -13077,7 +13476,7 @@ function createFixMissionTaskSymbol(coordinates, options = {}, _textAmplifiers =
13077
13476
  toPosition(zigzagExit),
13078
13477
  toPosition(tip)
13079
13478
  ],
13080
- createArrowHead$1(tip, along, arrowheadLength, arrowheadWidth)
13479
+ createArrowHead$2(tip, along, arrowheadLength, arrowheadWidth)
13081
13480
  ]
13082
13481
  }
13083
13482
  }, createLabelFeature(labelCenter, "F", labelRotation, options)]
@@ -15047,7 +15446,7 @@ function createPenetrateSymbol(coordinates, options = {}, _textAmplifiers = {},
15047
15446
  [coordinates[0], coordinates[1]],
15048
15447
  [unproject(tail[0], tail[1]), unproject(gapStart[0], gapStart[1])],
15049
15448
  [unproject(gapEnd[0], gapEnd[1]), unproject(tip[0], tip[1])],
15050
- createArrowHead$1(tip, arrowDirection, headLength, headWidth)
15449
+ createArrowHead$2(tip, arrowDirection, headLength, headWidth)
15051
15450
  ]
15052
15451
  }
15053
15452
  }, createLabelFeature(labelPoint, "P", labelRotation, {
@@ -15173,7 +15572,7 @@ function createPursuit(coordinates, options = {}, _textAmplifiers = {}, context
15173
15572
  const arc = sampleProjectedArc(center, vecSub(p2, center), vecScale(straightDirection, diameter / 2), 0, Math.PI, normalizeArcResolution(options.resolution) / 2);
15174
15573
  const arrowheadLength = diameter * Math.max(0, options.arrowheadLengthRatio ?? DEFAULT_PURSUIT_OPTIONS.arrowheadLengthRatio);
15175
15574
  const arrowheadWidth = diameter * Math.max(0, options.arrowheadWidthRatio ?? DEFAULT_PURSUIT_OPTIONS.arrowheadWidthRatio);
15176
- const arrowhead = createArrowHead$1(p3, vecScale(straightDirection, -1), arrowheadLength, arrowheadWidth);
15575
+ const arrowhead = createArrowHead$2(p3, vecScale(straightDirection, -1), arrowheadLength, arrowheadWidth);
15177
15576
  const terminalBarHalf = arrowheadWidth * Math.max(0, options.terminalBarLengthRatio ?? DEFAULT_PURSUIT_OPTIONS.terminalBarLengthRatio) / 2;
15178
15577
  const terminalStart = vecAdd(p3, vecScale(diameterAxis, terminalBarHalf));
15179
15578
  const terminalEnd = vecSub(p3, vecScale(diameterAxis, terminalBarHalf));
@@ -15489,7 +15888,7 @@ function createSecureSymbol(coordinates, options = {}, _textAmplifiers = {}, con
15489
15888
  const lines = [
15490
15889
  sampleArc(0, labelSweep - labelHalfGap),
15491
15890
  sampleArc(labelSweep + labelHalfGap, boundarySpan),
15492
- createArrowHead$1(terminal, tangent, radius * Math.max(0, resolved.arrowheadLengthRatio), radius * Math.max(0, resolved.arrowheadWidthRatio))
15891
+ createArrowHead$2(terminal, tangent, radius * Math.max(0, resolved.arrowheadLengthRatio), radius * Math.max(0, resolved.arrowheadWidthRatio))
15493
15892
  ];
15494
15893
  const labelPoint = arcPointAt(center, radiusVector, bulgeVector, labelSweep);
15495
15894
  return {
@@ -15684,7 +16083,7 @@ function createSeizeLikeSymbol(coordinates, options = {}, label = "S", part = "s
15684
16083
  circle,
15685
16084
  firstArcPart,
15686
16085
  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))
16086
+ 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
16087
  ];
15689
16088
  return {
15690
16089
  type: "FeatureCollection",
@@ -18698,6 +19097,10 @@ const DEFINITIONS = {
18698
19097
  guard: GUARD,
18699
19098
  isolate: ISOLATE,
18700
19099
  "movement-to-contact": MOVEMENT_TO_CONTACT,
19100
+ destroy: DESTROY,
19101
+ defeat: DEFEAT,
19102
+ neutralize: NEUTRALIZE,
19103
+ suppress: SUPPRESS,
18701
19104
  occupy: OCCUPY,
18702
19105
  penetrate: PENETRATE,
18703
19106
  pursuit: PURSUIT,
@@ -19135,4 +19538,4 @@ function assertNever(value) {
19135
19538
  throw new Error(`Unhandled control measure kind: ${String(value)}`);
19136
19539
  }
19137
19540
  //#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 };
19541
+ export { DEFAULT_PHASE_LINE_OPTIONS as $, project as $n, DEFAULT_DIRECTION_OF_SUPPORTING_ATTACK_OPTIONS as $t, DEFAULT_SECURE_OPTIONS as A, turnDrawRule as An, DEFAULT_GENERIC_CIRCLE_OPTIONS as At, DEFAULT_FORTIFIED_LINE_OPTIONS as B, occupyDrawRule as Bn, DEFAULT_BLOCK_ARROW_OPTIONS as Bt, DEFAULT_LANDING_ZONE_OPTIONS as C, computeInitialWidthPoint as Cn, RADAR_SEARCH_DOCTRINE_FILL_COLOR as Ct, DEFAULT_WITHDRAW_OPTIONS as D, line26DrawRule as Dn, DEFAULT_GENERIC_RECTANGLE_OPTIONS as Dt, DEFAULT_WITHDRAW_UNDER_PRESSURE_OPTIONS as E, line27DrawRule as En, DEFAULT_GENERIC_SECTOR_OPTIONS as Et, DEFAULT_PENETRATE_OPTIONS as F, sectorDrawRule as Fn, DEFAULT_BLOCK_MISSION_TASK_OPTIONS as Ft, DEFAULT_LIMIT_OF_ADVANCE_OPTIONS as G, area27DrawRule as Gn, DEFAULT_ATTACK_BY_FIRE_OPTIONS as Gt, PROBABLE_LINE_OF_DEPLOYMENT_DASH as H, blockDrawRule as Hn, DEFAULT_CONTAIN_OPTIONS as Ht, DEFAULT_OCCUPY_OPTIONS as I, staticPointDrawRule as In, DEFAULT_GENERIC_C2_LINE_OPTIONS as It, DEFAULT_FORWARD_EDGE_OF_BATTLE_AREA_OPTIONS as J, getMidpointPerpendicularSignedDistance as Jn, DEFAULT_ASSEMBLY_AREA_OPTIONS as Jt, DEFAULT_BATTLE_HANDOVER_LINE_OPTIONS as K, computeDefaultMidpointPerpendicularPoint as Kn, DEFAULT_ANTITANK_WALL_OPTIONS as Kt, DEFAULT_MOVEMENT_TO_CONTACT_OPTIONS as L, point12DrawRule as Ln, DEFAULT_ENGINEER_WORK_LINE_OPTIONS as Lt, DEFAULT_RETIRE_OPTIONS as M, ambushDrawRule as Mn, DEFAULT_CANALIZE_OPTIONS as Mt, DEFAULT_RELIEF_IN_PLACE_OPTIONS as N, attackByFireDrawRule as Nn, DEFAULT_BYPASS_OPTIONS as Nt, DEFAULT_EVACUATE_OPTIONS as O, line24DrawRule as On, DEFAULT_GENERIC_POLYGON_OPTIONS as Ot, DEFAULT_REARWARD_PASSAGE_OF_LINES_OPTIONS as P, dynamicPointDrawRule as Pn, DEFAULT_BREACH_OPTIONS as Pt, DEFAULT_BRIDGEHEAD_LINE_OPTIONS as Q, haversineDistance as Qn, DEFAULT_AREA_DEFENSE_OPTIONS as Qt, DEFAULT_FRONTAL_ATTACK_OPTIONS as R, secureDrawRule as Rn, DEFAULT_LIGHT_LINE_OPTIONS as Rt, DEFAULT_MAIN_ATTACK_OPTIONS as S, calculateMetrics as Sn, DEFAULT_RADAR_SEARCH_DOCTRINE_OPTIONS as St, DEFAULT_TURN_OPTIONS as T, polyline1DrawRule as Tn, DEFAULT_GENERIC_TEXT_OPTIONS as Tt, DEFAULT_LINE_OF_DEPARTURE_CONTACT_OPTIONS as U, centerRadiusDrawRule as Un, DEFAULT_BATTLE_POSITION_OPTIONS as Ut, DEFAULT_PROBABLE_LINE_OF_DEPLOYMENT_OPTIONS as V, disruptDrawRule as Vn, DEFAULT_BLOCK_OPTIONS as Vt, DEFAULT_LINE_OF_DEPARTURE_OPTIONS as W, containDrawRule as Wn, DEFAULT_ATTACK_HELICOPTER_OPTIONS as Wt, DEFAULT_HOLDING_LINE_OPTIONS as X, snapToMidpointPerpendicular as Xn, DEFAULT_MOBILE_DEFENSE_OPTIONS as Xt, DEFAULT_RELEASE_LINE_OPTIONS as Y, pointOnMidpointPerpendicularAxis as Yn, DEFAULT_AREA_OF_OPERATIONS_OPTIONS as Yt, DEFAULT_INFILTRATION_LANE_OPTIONS as Z, createBaselineFrame as Zn, DEFAULT_ENGAGEMENT_AREA_OPTIONS as Zt, DEFAULT_PICKUP_ZONE_OPTIONS as _, DEFAULT_STROKE_DASH_CSS_PIXELS 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, rectangleDrawRule 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, sphericalBearing as er, DEFAULT_FLOT_OPTIONS as et, DEFAULT_RETAIN_OPTIONS as f, DEFAULT_LABEL_HEIGHT_CSS_PIXELS as fn, DEFAULT_DELAY_OPTIONS as ft, DEFAULT_PRINCIPAL_DIRECTION_OF_FIRE_OPTIONS as g, DEFAULT_PORTRAYAL as gn, DEFAULT_COUNTERATTACK_OPTIONS as gt, SECONDARY_DIRECTION_OF_FIRE_DASH as h, DEFAULT_LINE_JOIN as hn, DEFAULT_COUNTERATTACK_BY_FIRE_OPTIONS as ht, CONTROL_MEASURE_METADATA as i, normalizeTextAmplifiers as in, roundToFixed as ir, DEFAULT_ENCIRCLEMENT_OPTIONS as it, DEFAULT_SCREEN_OPTIONS as j, line1DrawRule as jn, DEFAULT_CLASSIC_ARROW_OPTIONS as jt, DEFAULT_SEIZE_OPTIONS as k, line23DrawRule 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, DEFAULT_LINE_CAP as mn, DEFAULT_TACTICAL_ARROW_OPTIONS as mt, resolveStyleHints as n, TEXT_AMPLIFIER_FIELDS as nn, EPSILON 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, DEFAULT_LABEL_SIZE_CLAMP_CSS_PIXELS as pn, DEFAULT_COVER_OPTIONS as pt, DEFAULT_HANDOVER_LINE_OPTIONS as q, createMidpointPerpendicularDrawRule as qn, DEFAULT_ANTITANK_DITCH_OPTIONS as qt, CONTROL_MEASURE_IDS as r, canonicalTextAmplifierKey as rn, getMetersPerPixel 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, unproject 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, DEFAULT_STROKE_WIDTH_CSS_PIXELS as vn, DEFAULT_CORDON_AND_SEARCH_OPTIONS as vt, DEFAULT_JOINT_TACTICAL_ACTION_AREA_OPTIONS as w, supportByFireDrawRule as wn, RADAR_SEARCH_DOCTRINE_STROKE_COLOR as wt, DEFAULT_MINEFIELD_OPTIONS as x, axis1DrawRule as xn, DEFAULT_CLEAR_OPTIONS as xt, DEFAULT_OBSTACLE_BYPASS_EASY_OPTIONS as y, DEFAULT_SYMBOL_COLOR as yn, DEFAULT_CORDON_AND_KNOCK_OPTIONS as yt, DEFAULT_FORTIFIED_AREA_OPTIONS as z, penetrateDrawRule as zn, DEFAULT_BOUNDARY_OPTIONS as zt };