@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.
- package/README.md +4 -0
- package/dist/{index-CGoJeN1B.d.mts → index-CnRoitOO.d.mts} +65 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/preview/index.d.mts +1 -1
- package/dist/preview/index.mjs +1 -1
- package/dist/{renderControlMeasure-C2YRB8uJ.mjs → renderControlMeasure-C2er4uy_.mjs} +2362 -1959
- package/media/defeat.svg +1 -0
- package/media/destroy.svg +1 -0
- package/media/neutralize.svg +1 -0
- package/media/suppress.svg +1 -0
- package/package.json +1 -1
|
@@ -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/
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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/
|
|
1775
|
+
//#region src/style.ts
|
|
1776
1776
|
/**
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
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
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
/**
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
*
|
|
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
|
|
1892
|
-
let
|
|
1893
|
-
if (
|
|
1894
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
-
*
|
|
1904
|
-
*
|
|
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
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
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/
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
const
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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: "
|
|
2093
|
-
|
|
2094
|
-
|
|
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:
|
|
2097
|
-
max:
|
|
1890
|
+
min: 2,
|
|
1891
|
+
max: 64,
|
|
2098
1892
|
step: 1,
|
|
2099
|
-
|
|
1893
|
+
visibleWhen: (opts) => Boolean(opts.smooth)
|
|
2100
1894
|
}];
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
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
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
const
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
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
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
const
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
*
|
|
2351
|
-
*
|
|
2352
|
-
*
|
|
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
|
|
2359
|
-
const
|
|
2360
|
-
if (
|
|
2361
|
-
|
|
2362
|
-
|
|
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
|
-
*
|
|
2366
|
-
*
|
|
2367
|
-
*
|
|
2368
|
-
*
|
|
2369
|
-
*
|
|
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
|
|
2372
|
-
|
|
2373
|
-
|
|
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
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
-
|
|
2528
|
-
|
|
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
|
-
*
|
|
2548
|
-
*
|
|
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
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
-
*
|
|
2593
|
-
*
|
|
2594
|
-
*
|
|
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
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
-
|
|
2638
|
-
|
|
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
|
-
*
|
|
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
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
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
|
-
/**
|
|
2692
|
-
function
|
|
2693
|
-
return (
|
|
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
|
-
*
|
|
2697
|
-
* the
|
|
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
|
|
2700
|
-
const
|
|
2701
|
-
|
|
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
|
-
*
|
|
2709
|
-
*
|
|
2710
|
-
*
|
|
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
|
|
2713
|
-
if (
|
|
2714
|
-
const
|
|
2715
|
-
let
|
|
2716
|
-
|
|
2717
|
-
const
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
const
|
|
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
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
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
|
-
*
|
|
2736
|
-
* `
|
|
2737
|
-
*
|
|
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
|
|
2770
|
-
|
|
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
|
-
*
|
|
2774
|
-
*
|
|
2775
|
-
*
|
|
2776
|
-
*
|
|
2777
|
-
*
|
|
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
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
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
|
-
|
|
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
|
|
2806
|
-
const
|
|
2807
|
-
|
|
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
|
-
|
|
2810
|
-
|
|
2222
|
+
return nearest ? {
|
|
2223
|
+
...nearest,
|
|
2224
|
+
totalArc
|
|
2225
|
+
} : void 0;
|
|
2811
2226
|
}
|
|
2812
|
-
/**
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
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
|
-
*
|
|
2820
|
-
*
|
|
2821
|
-
*
|
|
2822
|
-
*
|
|
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
|
|
2825
|
-
|
|
2826
|
-
const
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
const
|
|
2836
|
-
const
|
|
2837
|
-
|
|
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
|
|
2273
|
+
return true;
|
|
2840
2274
|
}
|
|
2841
2275
|
/**
|
|
2842
|
-
*
|
|
2843
|
-
*
|
|
2844
|
-
*
|
|
2845
|
-
*
|
|
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
|
|
2848
|
-
const
|
|
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
|
-
|
|
2852
|
-
|
|
2853
|
-
const
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
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
|
-
|
|
2862
|
-
|
|
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
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
* (
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
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
|
|
2873
|
-
const
|
|
2874
|
-
|
|
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
|
-
/**
|
|
2877
|
-
function
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
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
|
-
/**
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
*
|
|
2898
|
-
*
|
|
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
|
|
2901
|
-
const
|
|
2902
|
-
|
|
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
|
-
*
|
|
2913
|
-
* the
|
|
2914
|
-
*
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
*
|
|
2919
|
-
*
|
|
2920
|
-
*
|
|
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
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
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
|
-
*
|
|
2950
|
-
*
|
|
2951
|
-
*
|
|
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
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
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
|
-
|
|
2973
|
-
|
|
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
|
-
*
|
|
2982
|
-
*
|
|
2983
|
-
*
|
|
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
|
|
2986
|
-
const
|
|
2987
|
-
|
|
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
|
-
*
|
|
2991
|
-
*
|
|
2992
|
-
*
|
|
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
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
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
|
-
*
|
|
3018
|
-
*
|
|
3019
|
-
*
|
|
3020
|
-
*
|
|
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
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
const
|
|
3026
|
-
const
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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
|
|
3044
|
-
*
|
|
3045
|
-
*
|
|
3046
|
-
*
|
|
3047
|
-
*
|
|
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
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
const
|
|
3053
|
-
const
|
|
3054
|
-
const
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
perp:
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
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
|
|
3092
|
-
...nearest,
|
|
3093
|
-
totalArc
|
|
3094
|
-
} : void 0;
|
|
2565
|
+
return frames;
|
|
3095
2566
|
}
|
|
3096
2567
|
/**
|
|
3097
|
-
*
|
|
3098
|
-
*
|
|
3099
|
-
*
|
|
3100
|
-
*
|
|
3101
|
-
*
|
|
3102
|
-
*
|
|
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
|
|
3105
|
-
const
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
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
|
-
/**
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|
-
*
|
|
3120
|
-
*
|
|
3121
|
-
*
|
|
3122
|
-
*
|
|
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
|
|
3128
|
-
const
|
|
3129
|
-
const
|
|
3130
|
-
const
|
|
3131
|
-
const
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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
|
-
*
|
|
3146
|
-
*
|
|
3147
|
-
* `
|
|
3148
|
-
*
|
|
3149
|
-
*
|
|
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
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
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
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
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
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
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
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
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
|
|
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
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
3294
|
-
*
|
|
3295
|
-
*
|
|
3296
|
-
*
|
|
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
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
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
|
-
|
|
3358
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3365
|
-
*
|
|
3366
|
-
*
|
|
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
|
|
3369
|
-
const
|
|
3370
|
-
|
|
3371
|
-
|
|
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
|
-
*
|
|
3375
|
-
*
|
|
3376
|
-
*
|
|
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
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
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
|
-
*
|
|
3390
|
-
*
|
|
3391
|
-
*
|
|
3392
|
-
*
|
|
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
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
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
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
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
|
|
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
|
-
*
|
|
3438
|
-
*
|
|
3439
|
-
*
|
|
3440
|
-
*
|
|
3441
|
-
*
|
|
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
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
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
|
-
*
|
|
3459
|
-
*
|
|
3460
|
-
*
|
|
3461
|
-
*
|
|
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
|
|
3464
|
-
const
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
11263
|
-
createArrowHead$
|
|
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$
|
|
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$
|
|
11901
|
+
createArrowHead$2(middleTip, arrowDirection, headLength, headWidth),
|
|
11503
11902
|
[[...p2LonLat], unproject(bottomTip[0], bottomTip[1])],
|
|
11504
|
-
createArrowHead$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 $,
|
|
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 };
|