@orbat-mapper/tactical-draw 0.2.0-alpha.30 → 0.2.0-alpha.32

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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveAmplifierPlacement, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
1
+ import { applyBoxTransformOptions, canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, getDefaultOptions, renderControlMeasure, resolveAmplifierPlacement, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
2
2
  //#region src/base-map-adapter.ts
3
3
  /**
4
4
  * Abstract base class shared by all `MapAdapter` implementations.
@@ -218,26 +218,17 @@ var BaseMapAdapter = class {
218
218
  return this.featureCache.get(layerId) ?? [];
219
219
  }
220
220
  };
221
- function pickHitTolerance(originalEvent) {
222
- if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
223
- if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return 12;
224
- return 4;
221
+ function pickPointerType(originalEvent) {
222
+ if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
223
+ if (originalEvent.pointerType === "touch") return "touch";
224
+ return originalEvent.pointerType === "pen" ? "pen" : "mouse";
225
+ }
226
+ if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return "touch";
227
+ if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickPointerType(originalEvent.originalEvent);
228
+ return "mouse";
225
229
  }
226
- /**
227
- * Resolve a pixel hit-tolerance for a `MapEvent.originalEvent` delivered by the
228
- * `MapAdapter.on(...)` pipeline. Unlike {@link PickEvent}, that pipeline forwards
229
- * the *engine wrapper* event (OpenLayers `MapBrowserEvent`, MapLibre
230
- * `MapMouseEvent`, Leaflet `LeafletMouseEvent`), each of which carries the raw
231
- * DOM event on a `.originalEvent` property. {@link pickHitTolerance} only
232
- * recognizes a real `PointerEvent` / `TouchEvent`, so unwrap one level when the
233
- * wrapper itself is not recognized as touch. Falls back to the mouse tolerance
234
- * when the input type cannot be determined.
235
- */
236
- function mapEventHitTolerance(originalEvent) {
237
- const direct = pickHitTolerance(originalEvent);
238
- if (direct === 12) return direct;
239
- if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickHitTolerance(originalEvent.originalEvent);
240
- return direct;
230
+ function pickHitTolerance(originalEvent) {
231
+ return pickPointerType(originalEvent) === "touch" ? 12 : 4;
241
232
  }
242
233
  function tryControlMeasureIdFromFeature(feature) {
243
234
  const graphicId = feature.properties?.graphicId;
@@ -268,6 +259,392 @@ function bindAbortable(signal, attach) {
268
259
  return dispose;
269
260
  }
270
261
  //#endregion
262
+ //#region src/pick-candidates.ts
263
+ /**
264
+ * The `visualOrder` for the feature at `index` of a render-ordered
265
+ * (bottom-first) list of `total` features: reversing the index makes the
266
+ * topmost-rendered feature the lowest, and therefore winning, order.
267
+ */
268
+ function renderOrderToVisualOrder(index, total) {
269
+ return total - 1 - index;
270
+ }
271
+ /**
272
+ * Preserve native visual ordering/hit evidence while keeping cached source
273
+ * geometry authoritative. Native polygon geometry may be tile-clipped and
274
+ * therefore contain artificial edges that are not part of the graphic.
275
+ *
276
+ * Normalizes to the shared `visualOrder` contract (lower = visually
277
+ * topmost): native hits already arrive topmost-first from the engines and
278
+ * keep that order at the front. Cached-only features (not among the native
279
+ * hits) are appended after them, in reverse cache order — cache arrays are
280
+ * render order (bottom-first), so reversing yields topmost-first — with
281
+ * `visualOrder` numbering continuing on from the native section.
282
+ */
283
+ function mergeNativeAndCachedPickFeatures(nativeFeatures, cachedFeatures) {
284
+ const cachedById = /* @__PURE__ */ new Map();
285
+ for (const feature of cachedFeatures) if (feature.id != null) cachedById.set(feature.id, feature);
286
+ const merged = [];
287
+ const seenIds = /* @__PURE__ */ new Set();
288
+ for (const nativeFeature of nativeFeatures) {
289
+ if (nativeFeature.id != null && seenIds.has(nativeFeature.id)) continue;
290
+ const feature = nativeFeature.id == null ? nativeFeature : cachedById.get(nativeFeature.id) ?? nativeFeature;
291
+ merged.push({
292
+ feature,
293
+ visualOrder: merged.length,
294
+ nativeHit: true
295
+ });
296
+ if (nativeFeature.id != null) seenIds.add(nativeFeature.id);
297
+ }
298
+ for (let i = cachedFeatures.length - 1; i >= 0; i--) {
299
+ const feature = cachedFeatures[i];
300
+ if (feature.id != null && seenIds.has(feature.id)) continue;
301
+ merged.push({
302
+ feature,
303
+ visualOrder: merged.length,
304
+ nativeHit: false
305
+ });
306
+ if (feature.id != null) seenIds.add(feature.id);
307
+ }
308
+ return merged;
309
+ }
310
+ /**
311
+ * Evaluate every relevant rendered part in screen space, then return the
312
+ * primary candidate under the visible-intent ranking policy: direct visible
313
+ * marks (strokes, points, icons, labels) outrank visibly filled interiors,
314
+ * which outrank inferred outline-only reach; competing direct marks rank by
315
+ * screen-space distance; visual topmost order is only the final tie-break.
316
+ * See `rankPickCandidates` for the full ordered list — that ordering stays
317
+ * internal adapter-author plumbing; the public pick event exposes only this
318
+ * primary candidate.
319
+ */
320
+ function evaluatePickCandidates(inputs, context) {
321
+ let best = null;
322
+ for (const input of inputs) {
323
+ const candidate = buildCandidate(input, context);
324
+ if (candidate && (best === null || compareCandidates(candidate, best) < 0)) best = candidate;
325
+ }
326
+ return best;
327
+ }
328
+ function buildCandidate(input, context) {
329
+ const presentation = input.presentation ?? resolvePickPresentation(input.feature, input.layerStyle);
330
+ const evaluated = evaluateInput(input, presentation, context);
331
+ if (!evaluated) return null;
332
+ return {
333
+ feature: input.feature,
334
+ graphicId: tryControlMeasureIdFromFeature(input.feature),
335
+ renderedPartId: typeof input.feature.id === "string" || typeof input.feature.id === "number" ? input.feature.id : null,
336
+ hit: evaluated.hit,
337
+ viewport: context.viewport,
338
+ presentation,
339
+ visualOrder: input.visualOrder,
340
+ ...evaluated.iconFootprint ? { iconFootprint: evaluated.iconFootprint } : {},
341
+ ...input.labelExtent ? { labelExtent: input.labelExtent } : {}
342
+ };
343
+ }
344
+ function compareCandidates(a, b) {
345
+ const rankA = selectionSurfaceRank(a);
346
+ const rankDifference = rankA - selectionSurfaceRank(b);
347
+ if (rankDifference !== 0) return rankDifference;
348
+ if (rankA === 0 && a.hit.distance !== null && b.hit.distance !== null) {
349
+ const distanceDifference = a.hit.distance - b.hit.distance;
350
+ if (distanceDifference !== 0) return distanceDifference;
351
+ }
352
+ return a.visualOrder - b.visualOrder;
353
+ }
354
+ function selectionSurfaceRank(candidate) {
355
+ if (candidate.hit.kind !== "polygon-interior") return 0;
356
+ return (candidate.presentation.fill?.opacity ?? 0) > 0 ? 1 : 2;
357
+ }
358
+ function resolvePickPresentation(feature, layerStyle) {
359
+ const featureStyle = asRecord(feature.properties?.style);
360
+ const strokeColor = stringValue(featureStyle?.strokeColor) ?? layerStyle?.strokeColor;
361
+ const strokeWidth = numberValue(featureStyle?.strokeWidth) ?? layerStyle?.strokeWidth;
362
+ const strokeDash = numberArray(featureStyle?.strokeDash) ?? layerStyle?.strokeDash;
363
+ const fillColor = stringValue(featureStyle?.fillColor) ?? layerStyle?.fillColor;
364
+ const fillPattern = stringValue(featureStyle?.fillPattern);
365
+ const pointFillColor = stringValue(featureStyle?.pointFillColor) ?? stringValue(featureStyle?.fillColor) ?? layerStyle?.pointFillColor ?? layerStyle?.fillColor;
366
+ const pointRadius = numberValue(featureStyle?.pointRadius) ?? layerStyle?.pointRadius;
367
+ const presentation = {};
368
+ if (strokeColor !== void 0 || strokeWidth !== void 0 || strokeDash !== void 0) presentation.stroke = {
369
+ ...strokeColor !== void 0 ? { color: strokeColor } : {},
370
+ ...strokeWidth !== void 0 ? { width: strokeWidth } : {},
371
+ ...strokeDash !== void 0 ? { dash: strokeDash } : {}
372
+ };
373
+ if (fillColor !== void 0 || fillPattern !== void 0) presentation.fill = {
374
+ ...fillColor !== void 0 ? { color: fillColor } : {},
375
+ ...fillPattern !== void 0 ? { pattern: fillPattern } : {},
376
+ opacity: colorOpacity(fillColor)
377
+ };
378
+ if (feature.geometry.type === "Point" && (pointFillColor !== void 0 || pointRadius !== void 0)) presentation.point = {
379
+ ...pointFillColor !== void 0 ? { fillColor: pointFillColor } : {},
380
+ ...pointRadius !== void 0 ? { radius: pointRadius } : {}
381
+ };
382
+ if (layerStyle?.text !== void 0) presentation.text = { ...layerStyle.text };
383
+ return presentation;
384
+ }
385
+ function evaluateInput(input, presentation, context) {
386
+ if (input.labelExtent && pointInExtent(context.pixel, input.labelExtent)) return { hit: {
387
+ kind: "label",
388
+ distance: 0
389
+ } };
390
+ if (input.icon) return evaluateIcon(input.feature, input.icon, context);
391
+ const geometryHit = evaluateGeometry(input.feature, presentation, context);
392
+ if (geometryHit) return { hit: geometryHit };
393
+ if (input.feature.geometry.type === "Polygon" || input.feature.geometry.type === "MultiPolygon") return null;
394
+ return input.nativeHit ? { hit: {
395
+ kind: inferNativeHitKind(input.feature),
396
+ distance: null
397
+ } } : null;
398
+ }
399
+ function inferNativeHitKind(feature) {
400
+ if (typeof feature.properties?.iconId === "string") return "icon";
401
+ if (typeof feature.properties?.text === "string") return "label";
402
+ if (feature.geometry.type === "Point") return "point";
403
+ if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") return "line";
404
+ return "native";
405
+ }
406
+ function evaluateGeometry(feature, presentation, context) {
407
+ const geometry = feature.geometry;
408
+ const { pixel, tolerance, toPixel } = context;
409
+ if (geometry.type === "Point") {
410
+ const projected = toPixel(geometry.coordinates);
411
+ if (!projected) return null;
412
+ const distance = pointDistance(pixel, projected);
413
+ return distance <= tolerance ? {
414
+ kind: "point",
415
+ distance
416
+ } : null;
417
+ }
418
+ if (geometry.type === "LineString") {
419
+ const distance = distanceToLine(pixel, geometry.coordinates, toPixel);
420
+ return distance !== null && distance <= tolerance ? {
421
+ kind: "line",
422
+ distance
423
+ } : null;
424
+ }
425
+ if (geometry.type === "MultiLineString") {
426
+ const distance = minimum(geometry.coordinates.map((line) => distanceToLine(pixel, line, toPixel)));
427
+ return distance !== null && distance <= tolerance ? {
428
+ kind: "line",
429
+ distance
430
+ } : null;
431
+ }
432
+ if (geometry.type === "Polygon") return evaluatePolygon(pixel, geometry.coordinates, presentation, context);
433
+ if (geometry.type === "MultiPolygon") return bestPolygonHit(geometry.coordinates.map((polygon) => evaluatePolygon(pixel, polygon, presentation, context)).filter((hit) => hit !== null));
434
+ return null;
435
+ }
436
+ function evaluatePolygon(pixel, rings, presentation, context) {
437
+ const { tolerance, toPixel, viewport } = context;
438
+ const projectedRings = rings.map((ring) => ring.map(toPixel));
439
+ const distance = hasVisibleBoundary(presentation) ? minimum(projectedRings.map((points) => minVisibleSegmentDistance(pixel, points, viewport))) : null;
440
+ if (distance !== null && distance <= tolerance) return {
441
+ kind: "polygon-boundary",
442
+ distance
443
+ };
444
+ if (!(projectedRings.length > 0 && pointInProjectedRing(pixel, projectedRings[0]) && !projectedRings.slice(1).some((points) => pointInProjectedRing(pixel, points)))) return null;
445
+ const visiblyFilled = (presentation.fill?.opacity ?? 0) > 0;
446
+ const inwardReach = context.pointerType === "touch" ? 36 : 24;
447
+ if (visiblyFilled || distance !== null && distance <= inwardReach) return {
448
+ kind: "polygon-interior",
449
+ distance
450
+ };
451
+ return null;
452
+ }
453
+ function bestPolygonHit(hits) {
454
+ if (hits.length === 0) return null;
455
+ const boundary = hits.filter((hit) => hit.kind === "polygon-boundary");
456
+ if (boundary.length > 0) return boundary.reduce((best, hit) => (hit.distance ?? Infinity) < (best.distance ?? Infinity) ? hit : best);
457
+ return hits[0];
458
+ }
459
+ function evaluateIcon(feature, resource, context) {
460
+ if (feature.geometry.type !== "Point") return null;
461
+ const anchor = context.toPixel(feature.geometry.coordinates);
462
+ if (!anchor) return null;
463
+ const rawScale = feature.properties?.iconScale;
464
+ const scale = typeof rawScale === "number" ? rawScale : 1;
465
+ if (!Number.isFinite(scale) || scale <= 0) return null;
466
+ const rawRotation = feature.properties?.iconRotation;
467
+ const angle = (typeof rawRotation === "number" && Number.isFinite(rawRotation) ? rawRotation : 0) - (context.bearing ?? 0);
468
+ const cos = Math.cos(angle);
469
+ const sin = Math.sin(angle);
470
+ const minX = -resource.anchor[0] * scale;
471
+ const maxX = (resource.width - resource.anchor[0]) * scale;
472
+ const minY = -resource.anchor[1] * scale;
473
+ const maxY = (resource.height - resource.anchor[1]) * scale;
474
+ const dx = context.pixel[0] - anchor[0];
475
+ const dy = context.pixel[1] - anchor[1];
476
+ const localX = dx * cos + dy * sin;
477
+ const localY = -dx * sin + dy * cos;
478
+ const outsideX = Math.max(minX - localX, 0, localX - maxX);
479
+ const outsideY = Math.max(minY - localY, 0, localY - maxY);
480
+ if (outsideX > context.tolerance || outsideY > context.tolerance) return null;
481
+ const coreRect = resource.coreRect ?? {
482
+ x: 0,
483
+ y: 0,
484
+ width: resource.width,
485
+ height: resource.height
486
+ };
487
+ const coreMinX = (coreRect.x - resource.anchor[0]) * scale;
488
+ const coreMaxX = (coreRect.x + coreRect.width - resource.anchor[0]) * scale;
489
+ const coreMinY = (coreRect.y - resource.anchor[1]) * scale;
490
+ const coreMaxY = (coreRect.y + coreRect.height - resource.anchor[1]) * scale;
491
+ const coreOutsideX = Math.max(coreMinX - localX, 0, localX - coreMaxX);
492
+ const coreOutsideY = Math.max(coreMinY - localY, 0, localY - coreMaxY);
493
+ const distance = Math.hypot(coreOutsideX, coreOutsideY);
494
+ const corner = (x, y) => [anchor[0] + x * cos - y * sin, anchor[1] + x * sin + y * cos];
495
+ return {
496
+ hit: {
497
+ kind: "icon",
498
+ distance
499
+ },
500
+ iconFootprint: [
501
+ corner(minX, minY),
502
+ corner(maxX, minY),
503
+ corner(maxX, maxY),
504
+ corner(minX, maxY)
505
+ ]
506
+ };
507
+ }
508
+ function distanceToLine(pixel, coordinates, toPixel) {
509
+ return minSegmentDistance(pixel, coordinates.map(toPixel));
510
+ }
511
+ /** Nearest distance from `pixel` to any segment of a pre-projected polyline. */
512
+ function minSegmentDistance(pixel, points) {
513
+ let closest = null;
514
+ for (let i = 0; i < points.length - 1; i++) {
515
+ const a = points[i];
516
+ const b = points[i + 1];
517
+ if (!a || !b) continue;
518
+ const distance = distanceToSegment(pixel, a, b);
519
+ if (distance !== null && (closest === null || distance < closest)) closest = distance;
520
+ }
521
+ return closest;
522
+ }
523
+ /** As `minSegmentDistance`, but each segment is first clipped to `viewport`. */
524
+ function minVisibleSegmentDistance(pixel, points, viewport) {
525
+ let closest = null;
526
+ for (let i = 0; i < points.length - 1; i++) {
527
+ const a = points[i];
528
+ const b = points[i + 1];
529
+ if (!a || !b) continue;
530
+ const visible = clipSegmentToBounds(a, b, viewport);
531
+ if (!visible) continue;
532
+ const [visibleA, visibleB] = visible;
533
+ const distance = visibleA[0] === visibleB[0] && visibleA[1] === visibleB[1] ? pointDistance(pixel, visibleA) : distanceToSegment(pixel, visibleA, visibleB);
534
+ if (distance !== null && (closest === null || distance < closest)) closest = distance;
535
+ }
536
+ return closest;
537
+ }
538
+ function clipSegmentToBounds(a, b, bounds) {
539
+ const dx = b[0] - a[0];
540
+ const dy = b[1] - a[1];
541
+ let start = 0;
542
+ let end = 1;
543
+ const edges = [
544
+ [-dx, a[0] - bounds.minX],
545
+ [dx, bounds.maxX - a[0]],
546
+ [-dy, a[1] - bounds.minY],
547
+ [dy, bounds.maxY - a[1]]
548
+ ];
549
+ for (const [direction, offset] of edges) {
550
+ if (direction === 0) {
551
+ if (offset < 0) return null;
552
+ continue;
553
+ }
554
+ const ratio = offset / direction;
555
+ if (direction < 0) start = Math.max(start, ratio);
556
+ else end = Math.min(end, ratio);
557
+ if (start > end) return null;
558
+ }
559
+ return [[a[0] + start * dx, a[1] + start * dy], [a[0] + end * dx, a[1] + end * dy]];
560
+ }
561
+ function distanceToSegment(pixel, a, b) {
562
+ const dx = b[0] - a[0];
563
+ const dy = b[1] - a[1];
564
+ const lengthSq = dx * dx + dy * dy;
565
+ if (lengthSq === 0) return null;
566
+ const unclamped = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
567
+ const t = Math.max(0, Math.min(1, unclamped));
568
+ return Math.hypot(pixel[0] - (a[0] + t * dx), pixel[1] - (a[1] + t * dy));
569
+ }
570
+ /**
571
+ * Even-odd point-in-ring test over a pre-projected ring. Any vertex that
572
+ * failed to project makes the whole ring inconclusive, matching the prior
573
+ * "project every vertex or bail" contract.
574
+ */
575
+ function pointInProjectedRing(pixel, points) {
576
+ let inside = false;
577
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
578
+ const pi = points[i];
579
+ const pj = points[j];
580
+ if (!pi || !pj) return false;
581
+ const [xi, yi] = pi;
582
+ const [xj, yj] = pj;
583
+ if (yi > pixel[1] !== yj > pixel[1] && pixel[0] < (xj - xi) * (pixel[1] - yi) / (yj - yi) + xi) inside = !inside;
584
+ }
585
+ return inside;
586
+ }
587
+ function pointInExtent(pixel, extent) {
588
+ if ("minX" in extent) return pixel[0] >= extent.minX && pixel[0] <= extent.maxX && pixel[1] >= extent.minY && pixel[1] <= extent.maxY;
589
+ const dx = pixel[0] - extent.center[0];
590
+ const dy = pixel[1] - extent.center[1];
591
+ const cos = Math.cos(-extent.angle);
592
+ const sin = Math.sin(-extent.angle);
593
+ const localX = dx * cos - dy * sin;
594
+ const localY = dx * sin + dy * cos;
595
+ return Math.abs(localX) <= extent.halfWidth && Math.abs(localY) <= extent.halfHeight;
596
+ }
597
+ function pointDistance(a, b) {
598
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
599
+ }
600
+ function minimum(values) {
601
+ let result = null;
602
+ for (const value of values) if (value !== null && (result === null || value < result)) result = value;
603
+ return result;
604
+ }
605
+ function colorOpacity(color) {
606
+ if (color === void 0) return 0;
607
+ const normalized = color.trim().toLowerCase();
608
+ if (normalized === "transparent") return 0;
609
+ if (/^#[0-9a-f]{4}$/i.test(normalized)) return parseInt(normalized[4], 16) / 15;
610
+ if (/^#[0-9a-f]{8}$/i.test(normalized)) return parseInt(normalized.slice(7, 9), 16) / 255;
611
+ const functional = normalized.match(/^[a-z]+\((.*)\)$/);
612
+ if (functional) {
613
+ const body = functional[1];
614
+ const slashAlpha = body.match(/\/\s*([+-]?(?:\d*\.)?\d+)(%)?\s*$/);
615
+ if (slashAlpha) return normalizedAlpha(slashAlpha[1], slashAlpha[2] === "%");
616
+ const commaParts = body.split(",").map((part) => part.trim());
617
+ if (commaParts.length === 4) {
618
+ const alpha = commaParts[3].match(/^([+-]?(?:\d*\.)?\d+)(%)?$/);
619
+ if (alpha) return normalizedAlpha(alpha[1], alpha[2] === "%");
620
+ }
621
+ }
622
+ return 1;
623
+ }
624
+ function normalizedAlpha(value, percentage) {
625
+ const alpha = Number(value);
626
+ if (!Number.isFinite(alpha)) return 1;
627
+ return Math.max(0, Math.min(1, percentage ? alpha / 100 : alpha));
628
+ }
629
+ function hasVisibleBoundary(presentation) {
630
+ const stroke = presentation.stroke;
631
+ if (!stroke || stroke.color === void 0) return false;
632
+ if (stroke.width !== void 0 && stroke.width <= 0) return false;
633
+ return colorOpacity(stroke.color) > 0;
634
+ }
635
+ function asRecord(value) {
636
+ return value !== null && typeof value === "object" ? value : void 0;
637
+ }
638
+ function stringValue(value) {
639
+ return typeof value === "string" ? value : void 0;
640
+ }
641
+ function numberValue(value) {
642
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
643
+ }
644
+ function numberArray(value) {
645
+ return Array.isArray(value) && value.every((item) => typeof item === "number") ? [...value] : void 0;
646
+ }
647
+ //#endregion
271
648
  //#region src/feature-style.ts
272
649
  /**
273
650
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -296,6 +673,33 @@ function resolveGroundTextSizePixels(textSizeMeters, resolution, text) {
296
673
  const max = text?.textSizeMaxPixels ?? 24;
297
674
  return Math.min(max, Math.max(min, textSizeMeters / resolution));
298
675
  }
676
+ /**
677
+ * Resolve a Text graphic's on-screen size in pixels, honoring the
678
+ * `textMaxSizePixels` hide threshold (ADR-0037). Returns `null` — the "hidden"
679
+ * signal — when the graphic should render no text this frame; adapters emit no
680
+ * text style for a `null`. Distinct from [[resolveGroundTextSizePixels]], which
681
+ * keeps the 8–24 px clamp band for amplifier labels: this helper is the Text
682
+ * graphic's own sizing rule.
683
+ *
684
+ * - screen-anchored (`textSizePixels`): the fixed pixel size, never scaled or
685
+ * hidden by zoom.
686
+ * - ground-anchored (`textSizeMeters`) with a `textMaxSizePixels` threshold:
687
+ * *unclamped* `meters / resolution` (the 8–24 band does NOT apply), hidden
688
+ * (`null`) once that exceeds the threshold — text shrinks freely zooming out
689
+ * and vanishes rather than clamps zooming in.
690
+ * - ground-anchored without a threshold: falls back to the clamped band.
691
+ * - no usable size or resolution: `null`.
692
+ */
693
+ function resolveTextSizePixels(props, resolution) {
694
+ if (typeof props.textSizePixels === "number") return props.textSizePixels;
695
+ const meters = props.textSizeMeters;
696
+ if (typeof meters !== "number" || !(resolution > 0)) return null;
697
+ if (typeof props.textMaxSizePixels === "number") {
698
+ const px = meters / resolution;
699
+ return px > props.textMaxSizePixels ? null : px;
700
+ }
701
+ return resolveGroundTextSizePixels(meters, resolution, props);
702
+ }
299
703
  //#endregion
300
704
  //#region src/gestures/hit-test.ts
301
705
  /** Clamped parameter t ∈ [0, 1] of `pixel` projected onto the segment a→b in pixel space, or null for a zero-length segment. */
@@ -307,41 +711,6 @@ function clampedSegmentParameter(pixel, a, b) {
307
711
  const t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
308
712
  return Math.max(0, Math.min(1, t));
309
713
  }
310
- /** Distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
311
- function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
312
- const a = toPixel(p1);
313
- const b = toPixel(p2);
314
- if (!a || !b) return null;
315
- const t = clampedSegmentParameter(pixel, a, b);
316
- if (t === null) return null;
317
- const ddx = pixel[0] - (a[0] + t * (b[0] - a[0]));
318
- const ddy = pixel[1] - (a[1] + t * (b[1] - a[1]));
319
- return Math.sqrt(ddx * ddx + ddy * ddy);
320
- }
321
- /** Even-odd point-in-ring test in pixel space. */
322
- function pixelPointInRing(pixel, ring, toPixel) {
323
- const projected = [];
324
- for (const c of ring) {
325
- const p = toPixel(c);
326
- if (!p) return false;
327
- projected.push(p);
328
- }
329
- const [px, py] = pixel;
330
- let inside = false;
331
- for (let i = 0, j = projected.length - 1; i < projected.length; j = i++) {
332
- const xi = projected[i][0], yi = projected[i][1];
333
- const xj = projected[j][0], yj = projected[j][1];
334
- if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
335
- }
336
- return inside;
337
- }
338
- function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
339
- for (let i = 0; i < ring.length - 1; i++) {
340
- const d = pixelDistanceToSegment(pixel, ring[i], ring[i + 1], toPixel);
341
- if (d !== null && d <= hitTolerance) return true;
342
- }
343
- return false;
344
- }
345
714
  /**
346
715
  * Test whether `pixel` hits any feature in `features`, in pixel space.
347
716
  *
@@ -354,23 +723,28 @@ function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
354
723
  * Returns the first matching feature in iteration order, or null.
355
724
  */
356
725
  function hitTestFeature(pixel, features, hitTolerance, toPixel) {
357
- const [px, py] = pixel;
358
- for (const f of features) {
359
- if (!f) continue;
360
- const geom = f.geometry;
361
- let hit = false;
362
- if (geom.type === "Point") {
363
- const p = toPixel(geom.coordinates);
364
- if (p) {
365
- const dx = p[0] - px;
366
- const dy = p[1] - py;
367
- hit = Math.sqrt(dx * dx + dy * dy) <= hitTolerance;
368
- }
369
- } else if (geom.type === "LineString") hit = isNearAnySegment(pixel, geom.coordinates, hitTolerance, toPixel);
370
- else if (geom.type === "MultiLineString") hit = geom.coordinates.some((line) => isNearAnySegment(pixel, line, hitTolerance, toPixel));
371
- else if (geom.type === "Polygon") hit = geom.coordinates.some((ring) => pixelPointInRing(pixel, ring, toPixel)) || geom.coordinates.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel));
372
- else if (geom.type === "MultiPolygon") hit = geom.coordinates.some((poly) => poly.some((ring) => pixelPointInRing(pixel, ring, toPixel))) || geom.coordinates.some((poly) => poly.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel)));
373
- if (hit) return f;
726
+ for (const feature of features) {
727
+ if (!feature) continue;
728
+ const candidate = evaluatePickCandidates([{
729
+ feature,
730
+ visualOrder: 0,
731
+ ...feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon" ? { presentation: {
732
+ stroke: {
733
+ color: "#000",
734
+ width: 1
735
+ },
736
+ fill: {
737
+ color: "#000",
738
+ opacity: 1
739
+ }
740
+ } } : {}
741
+ }], {
742
+ pixel,
743
+ tolerance: hitTolerance,
744
+ toPixel,
745
+ viewport: UNBOUNDED_VIEWPORT
746
+ });
747
+ if (candidate) return candidate.feature;
374
748
  }
375
749
  return null;
376
750
  }
@@ -381,26 +755,24 @@ function hitTestFeature(pixel, features, hitTolerance, toPixel) {
381
755
  * unscaled SVG viewport. A positive tolerance pads all four sides.
382
756
  */
383
757
  function hitTestIconFeature(pixel, feature, resource, hitTolerance, toPixel, bearing = 0) {
384
- if (feature.geometry.type !== "Point") return false;
385
- const anchor = toPixel(feature.geometry.coordinates);
386
- if (!anchor) return false;
387
- const rawScale = feature.properties?.iconScale;
388
- const scale = typeof rawScale === "number" ? rawScale : 1;
389
- if (!Number.isFinite(scale) || scale <= 0) return false;
390
- const rawRotation = feature.properties?.iconRotation;
391
- const angle = (typeof rawRotation === "number" && Number.isFinite(rawRotation) ? rawRotation : 0) - bearing;
392
- const cos = Math.cos(angle);
393
- const sin = Math.sin(angle);
394
- const dx = pixel[0] - anchor[0];
395
- const dy = pixel[1] - anchor[1];
396
- const localX = dx * cos + dy * sin;
397
- const localY = -dx * sin + dy * cos;
398
- const minX = -resource.anchor[0] * scale - hitTolerance;
399
- const maxX = (resource.width - resource.anchor[0]) * scale + hitTolerance;
400
- const minY = -resource.anchor[1] * scale - hitTolerance;
401
- const maxY = (resource.height - resource.anchor[1]) * scale + hitTolerance;
402
- return localX >= minX && localX <= maxX && localY >= minY && localY <= maxY;
758
+ return evaluatePickCandidates([{
759
+ feature,
760
+ visualOrder: 0,
761
+ icon: resource
762
+ }], {
763
+ pixel,
764
+ tolerance: hitTolerance,
765
+ toPixel,
766
+ viewport: UNBOUNDED_VIEWPORT,
767
+ bearing
768
+ }) !== null;
403
769
  }
770
+ const UNBOUNDED_VIEWPORT = {
771
+ minX: Number.NEGATIVE_INFINITY,
772
+ minY: Number.NEGATIVE_INFINITY,
773
+ maxX: Number.POSITIVE_INFINITY,
774
+ maxY: Number.POSITIVE_INFINITY
775
+ };
404
776
  //#endregion
405
777
  //#region src/gestures/hit-tolerance.ts
406
778
  const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -684,6 +1056,10 @@ const PIXEL_SIZE_OPTIONS = {
684
1056
  "antitank-wall": {
685
1057
  pixels: "toothHeightPixels",
686
1058
  meters: "toothHeight"
1059
+ },
1060
+ text: {
1061
+ pixels: "sizePixels",
1062
+ meters: "sizeMeters"
687
1063
  }
688
1064
  };
689
1065
  /** The shared label-size pair every `capturesLabelSize` kind carries (ADR-0028). */
@@ -745,6 +1121,34 @@ function getSizeAnchor(measure) {
745
1121
  return measureUsesAdapterResolution(measure) ? "screen" : "ground";
746
1122
  }
747
1123
  /**
1124
+ * Materialise a *pixel-authored* kind's default pixel size onto a measure that
1125
+ * carries neither size key, so the ADR-0020 ground bake has a pixel value to
1126
+ * resolve to meters at commit. A kind is pixel-authored when its default
1127
+ * options declare the pixel-size option but no meter counterpart (e.g. Text's
1128
+ * `sizePixels: 24` with no `sizeMeters`) — such a fresh draw is authored in
1129
+ * pixels and must bake to meters, but a default draw commits with no options,
1130
+ * so the pixel size would otherwise never land and the graphic would persist
1131
+ * screen-anchored forever. No-op when the measure already carries a size key,
1132
+ * the kind has no pixel-size option, or the kind's default is meter-denominated
1133
+ * (e.g. FLOT, whose meter default governs a default draw without any bake) —
1134
+ * so this never changes the committed size of the pre-existing pixel kinds.
1135
+ */
1136
+ function withDefaultAuthoringSize(measure) {
1137
+ const spec = PIXEL_SIZE_OPTIONS[measure.kind];
1138
+ if (!spec) return measure;
1139
+ const options = measure.options ?? {};
1140
+ if (options[spec.pixels] !== void 0 || options[spec.meters] !== void 0) return measure;
1141
+ const defaults = getDefaultOptions(measure.kind);
1142
+ if (defaults[spec.meters] !== void 0 || defaults[spec.pixels] === void 0) return measure;
1143
+ return {
1144
+ ...measure,
1145
+ options: {
1146
+ ...options,
1147
+ [spec.pixels]: defaults[spec.pixels]
1148
+ }
1149
+ };
1150
+ }
1151
+ /**
748
1152
  * Bake a screen-anchored (pixel) size to a ground-anchored (meter) size at the
749
1153
  * adapter's current resolution, then strip the pixel + `metersPerPixel` keys so
750
1154
  * the result is an ordinary meters graphic carrying no pixel intent — afterward
@@ -1535,7 +1939,7 @@ function runFixedLengthDraw(options) {
1535
1939
  }
1536
1940
  function buildSnapshot() {
1537
1941
  const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
1538
- const captured = withCommittedAdapterMeasureOptions({
1942
+ const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
1539
1943
  id: generateId(),
1540
1944
  kind,
1541
1945
  controlPoints: finalPoints,
@@ -1543,8 +1947,8 @@ function runFixedLengthDraw(options) {
1543
1947
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
1544
1948
  ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1545
1949
  ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1546
- });
1547
- const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
1950
+ }));
1951
+ const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
1548
1952
  return {
1549
1953
  graphic: cloneControlMeasure(measure),
1550
1954
  render: renderMeasureForAdapter(adapter, measure, renderOpts)
@@ -1750,7 +2154,7 @@ function runVariableLengthDraw(options) {
1750
2154
  if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
1751
2155
  const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
1752
2156
  if (!finishPixel) continue;
1753
- const tol = mapEventHitTolerance(event.originalEvent);
2157
+ const tol = pickHitTolerance(event.originalEvent);
1754
2158
  const dx = event.pixel[0] - finishPixel[0];
1755
2159
  const dy = event.pixel[1] - finishPixel[1];
1756
2160
  if (dx * dx + dy * dy <= tol * tol) {
@@ -1815,7 +2219,7 @@ function runVariableLengthDraw(options) {
1815
2219
  renderPreview();
1816
2220
  }
1817
2221
  function buildSnapshot() {
1818
- const built = withCommittedAdapterMeasureOptions({
2222
+ const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
1819
2223
  id: generateId(),
1820
2224
  kind,
1821
2225
  controlPoints: committed.map((p) => [...p]),
@@ -1823,8 +2227,8 @@ function runVariableLengthDraw(options) {
1823
2227
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
1824
2228
  ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1825
2229
  ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1826
- });
1827
- const measure = sizeAnchor === "screen" ? built : bakePixelSizeToGround(built, adapter);
2230
+ }));
2231
+ const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
1828
2232
  return {
1829
2233
  graphic: cloneControlMeasure(measure),
1830
2234
  render: renderMeasureForAdapter(adapter, measure, renderOpts)
@@ -2182,6 +2586,21 @@ function normalizeRotation(angle) {
2182
2586
  function constrainRotation(angle) {
2183
2587
  return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
2184
2588
  }
2589
+ //#endregion
2590
+ //#region src/tactical-draw/transform-box.ts
2591
+ /**
2592
+ * The uniform scale factor and rotation delta a box implies relative to a
2593
+ * pre-drag box, given the gesture kind. A `body` translate reports neither
2594
+ * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2595
+ * (folding the delta into rotation/size options); the delta is pivot-
2596
+ * independent so either can apply it without knowing the box center.
2597
+ */
2598
+ function boxScaleRotation(kind, box, preDragBox) {
2599
+ return {
2600
+ scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2601
+ rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2602
+ };
2603
+ }
2185
2604
  function rotate(x, y, angle) {
2186
2605
  const cos = Math.cos(angle);
2187
2606
  const sin = Math.sin(angle);
@@ -2720,12 +3139,16 @@ function normalizeAngle(angle) {
2720
3139
  * grows away from the anchor) or local −x for `"end"` (text grows back
2721
3140
  * toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
2722
3141
  * screen (y-down) convention this offset needs.
3142
+ * - **Hidden**: a ground-anchored size past `textMaxSizePixels` (ADR-0037)
3143
+ * returns `null` — the box tracks `resolveTextSizePixels`'s hide rule
3144
+ * rather than keep boxing text that no adapter is drawing.
2723
3145
  */
2724
3146
  function fitLabelTransformBox(input) {
2725
- const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
3147
+ const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing, unclamped, textMaxSizePixels } = input;
2726
3148
  if (!text) return null;
3149
+ if (typeof textMaxSizePixels === "number" && textSizePixels === void 0 && typeof textSizeMeters === "number" && typeof resolution === "number" && resolution > 0 && textSizeMeters / resolution > textMaxSizePixels) return null;
2727
3150
  const angle = normalizeAngle(Math.PI - (rotation ?? 0) - (bearing ?? 0));
2728
- const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
3151
+ const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? unclamped ? Math.max(1, textSizeMeters / resolution) : resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
2729
3152
  const lines = text.split("\n");
2730
3153
  const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
2731
3154
  const height = lines.length * LINE_HEIGHT_FACTOR * h;
@@ -2760,12 +3183,47 @@ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
2760
3183
  textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
2761
3184
  textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
2762
3185
  textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
3186
+ textMaxSizePixels: typeof props.textMaxSizePixels === "number" ? props.textMaxSizePixels : void 0,
2763
3187
  anchor: anchorPixel,
2764
3188
  resolution: view.resolution,
2765
- bearing: view.bearing
3189
+ bearing: view.bearing,
3190
+ unclamped: view.unclamped
3191
+ });
3192
+ }
3193
+ /**
3194
+ * Fit a `TransformBox` around a rendered **Text generic graphic** feature — the
3195
+ * Text analogue of the point-symbol footprint (ADR-0031): a rotated logical
3196
+ * rectangle centered on / shifted from the anchor per `textAlign` (via the
3197
+ * feature's `textAnchor` prop), sized from the text's line count and longest
3198
+ * line at the actual rendered height (screen `textSizePixels` as-is; ground
3199
+ * `textSizeMeters / resolution` unclamped). Drives the Text edit/transform box
3200
+ * outline, its corner-scale handles, and the pick extent, and updates live as a
3201
+ * rotate/scale gesture folds new rotation/size into the feature. Returns `null`
3202
+ * when the feature carries no text. Rotation pivots at the anchor, so the box
3203
+ * orbits the anchor when the text rotates — matching how the adapters render a
3204
+ * `textAnchor`-shifted, rotated label.
3205
+ */
3206
+ function fitTextGraphicFootprint(feature, anchorPixel, view) {
3207
+ return fitLabelBoxFromFeature(feature, anchorPixel, {
3208
+ ...view,
3209
+ unclamped: true
2766
3210
  });
2767
3211
  }
2768
3212
  /**
3213
+ * Locate the single Text `Point` feature in `features`, project its anchor, and
3214
+ * return its {@link fitTextGraphicFootprint} box — the shared body behind the
3215
+ * edit controller's Text box and the group transform-controller's Text member
3216
+ * footprint. Returns `null` when there is no text feature or its anchor is
3217
+ * off-screen.
3218
+ */
3219
+ function fitTextFootprintFromFeatures(features, toPixel, view) {
3220
+ const f = features.find((feat) => feat.geometry?.type === "Point" && typeof feat.properties?.text === "string");
3221
+ if (!f || f.geometry?.type !== "Point") return null;
3222
+ const anchorPixel = toPixel(f.geometry.coordinates);
3223
+ if (!anchorPixel) return null;
3224
+ return fitTextGraphicFootprint(f, anchorPixel, view);
3225
+ }
3226
+ /**
2769
3227
  * Carry user-adjusted label anchors (and rotation overrides) through a
2770
3228
  * transform-box gesture. Anchors are transformed in pixel space alongside the
2771
3229
  * control points so the result remains visually rigid across map
@@ -3123,6 +3581,7 @@ function runEdit(opts) {
3123
3581
  const closedRing = rule?.closedRing ?? false;
3124
3582
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
3125
3583
  const isAreaKind = metadata.geometry === "area";
3584
+ const isTextGraphic = measure.kind === "text";
3126
3585
  const requestedModes = normaliseModes(initialModes);
3127
3586
  const editStartPoints = measure.controlPoints.map((p) => [...p]);
3128
3587
  const workingPoints = editStartPoints.map((p) => [...p]);
@@ -3252,6 +3711,7 @@ function runEdit(opts) {
3252
3711
  * projects (e.g. the working copy renders no features this frame).
3253
3712
  */
3254
3713
  function fitBoxFromFeatures(features) {
3714
+ if (isTextGraphic) return fitTextBox(features);
3255
3715
  const positions = [];
3256
3716
  for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
3257
3717
  const pixels = [];
@@ -3262,13 +3722,28 @@ function runEdit(opts) {
3262
3722
  return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
3263
3723
  }
3264
3724
  /**
3725
+ * The Text footprint box (rotated rectangle hugging the rendered text),
3726
+ * derived from the single Point feature's text props. Unlike geometry-fit
3727
+ * boxes it carries the text's own on-screen angle (so it tilts with the
3728
+ * rotation option) and honors `textAnchor` alignment; the session `boxAngle`
3729
+ * is not used for Text.
3730
+ */
3731
+ function fitTextBox(features) {
3732
+ return fitTextFootprintFromFeatures(features, driver.toPixel, {
3733
+ resolution: adapter.getResolution(),
3734
+ bearing: adapter.getBearing()
3735
+ });
3736
+ }
3737
+ /**
3265
3738
  * Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
3266
3739
  * in flight — that drag paints the box rigidly from its own snapshot
3267
3740
  * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
3268
- * doesn't fight its own paint.
3741
+ * doesn't fight its own paint. Text is the exception: its box follows the
3742
+ * rendered footprint live (a rotate/scale gesture changes the rendered text),
3743
+ * so it re-fits every frame, drag included.
3269
3744
  */
3270
3745
  function refitBox() {
3271
- if (activeBoxDrag) return;
3746
+ if (activeBoxDrag && !isTextGraphic) return;
3272
3747
  box = fitBoxFromFeatures(lastPreviewFeatures);
3273
3748
  }
3274
3749
  function liftOriginalFromGraphics() {
@@ -3319,7 +3794,7 @@ function runEdit(opts) {
3319
3794
  }), handleIdPrefix, interactionStyle);
3320
3795
  if (modes.includes("labeldrag")) features.push(...labels.handleFeatures());
3321
3796
  if (modes.includes("transform")) {
3322
- const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
3797
+ const liveBox = activeBoxDrag && !isTextGraphic ? activeBoxDrag.lastSnapshot.box : box;
3323
3798
  if (liveBox) {
3324
3799
  features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
3325
3800
  features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
@@ -3328,6 +3803,19 @@ function runEdit(opts) {
3328
3803
  ctx.writeLayer(handlesLayer, features);
3329
3804
  }
3330
3805
  /**
3806
+ * Repaint the preview and, in lockstep, the handles. `renderPreview` re-fits
3807
+ * `box` (for Text, the footprint depends on options — size/rotation/
3808
+ * alignment/text), so the transform-box outline and its corner/rotate handles
3809
+ * must repaint to match; otherwise they stay where the old options placed them
3810
+ * and the pick target (`box`) drifts out from under the visible handles
3811
+ * (#scale-handle desync). Used by the non-geometry option/style/amplifier
3812
+ * setters, which change what the preview renders without moving a point.
3813
+ */
3814
+ function renderPreviewAndHandles() {
3815
+ renderPreview();
3816
+ renderHandlesLayer();
3817
+ }
3818
+ /**
3331
3819
  * Builds the committed snapshot against explicit `options` rather than the
3332
3820
  * live `workingOptions` — used to render a gesture's "previous" snapshot
3333
3821
  * against pre-freeze options when a rotate has already merged its
@@ -3472,7 +3960,7 @@ function runEdit(opts) {
3472
3960
  replaceWorkingOptions(previousOptions);
3473
3961
  throw err;
3474
3962
  }
3475
- renderPreview();
3963
+ renderPreviewAndHandles();
3476
3964
  nonGeometryDirty = true;
3477
3965
  dirty = true;
3478
3966
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
@@ -3498,7 +3986,7 @@ function runEdit(opts) {
3498
3986
  currentStyle = previousStyle;
3499
3987
  throw err;
3500
3988
  }
3501
- renderPreview();
3989
+ renderPreviewAndHandles();
3502
3990
  nonGeometryDirty = true;
3503
3991
  dirty = true;
3504
3992
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
@@ -3544,7 +4032,7 @@ function runEdit(opts) {
3544
4032
  workingTextAmplifiers = previousTextAmplifiers;
3545
4033
  throw err;
3546
4034
  }
3547
- renderPreview();
4035
+ renderPreviewAndHandles();
3548
4036
  nonGeometryDirty = true;
3549
4037
  dirty = true;
3550
4038
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
@@ -3571,7 +4059,7 @@ function runEdit(opts) {
3571
4059
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
3572
4060
  if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
3573
4061
  const currentRendered = rebuildRendered();
3574
- renderPreview();
4062
+ renderPreviewAndHandles();
3575
4063
  nonGeometryDirty = true;
3576
4064
  dirty = true;
3577
4065
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
@@ -3917,11 +4405,40 @@ function runEdit(opts) {
3917
4405
  recomputeDirty();
3918
4406
  emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
3919
4407
  }
4408
+ /**
4409
+ * Fold a box gesture's delta (the box `to` relative to `from`, given the
4410
+ * gesture `kind`) into a Text graphic's `rotation`/size options, on top of
4411
+ * `base`. A `scale` grows the active size anchor; a `rotate` adds to the
4412
+ * rotation. Returns `base` unchanged when the kind produces no patch. The
4413
+ * delta (uniform scale ratio, angle difference) is pivot-independent, so
4414
+ * folding it keeps the anchor point fixed while the glyph turns/grows.
4415
+ */
4416
+ function foldTextTransform(from, to, kind, base) {
4417
+ const { scale, rotation } = boxScaleRotation(kind, to, from);
4418
+ const patch = applyBoxTransformOptions(measure.kind, base ?? {}, {
4419
+ scale,
4420
+ rotationRadians: rotation
4421
+ });
4422
+ return patch ? {
4423
+ ...base ?? {},
4424
+ ...patch
4425
+ } : base;
4426
+ }
3920
4427
  function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
3921
4428
  if (!activeBoxDrag) return;
3922
4429
  const resolvedPixel = translatedBoxPixel(pixel, info, activeBoxDrag);
3923
4430
  const snapshot = activeBoxDrag.fsm.move(resolvedPixel, { constrainRotation });
3924
4431
  activeBoxDrag.lastSnapshot = snapshot;
4432
+ if (isTextGraphic && (activeBoxDrag.fsm.kind === "rotate" || activeBoxDrag.fsm.kind === "scale")) {
4433
+ workingPoints.splice(0, workingPoints.length, ...activeBoxDrag.preDragPoints);
4434
+ workingOptions = foldTextTransform(activeBoxDrag.preDragBox, snapshot.box, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptions);
4435
+ nonGeometryDirty = true;
4436
+ renderPreview();
4437
+ renderHandlesLayer();
4438
+ recomputeDirty();
4439
+ emitTransient("move", activeBoxDrag.preDragPoints, null);
4440
+ return;
4441
+ }
3925
4442
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3926
4443
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
3927
4444
  renderPreview();
@@ -4012,9 +4529,15 @@ function runEdit(opts) {
4012
4529
  const finalSnapshot = fsm.move(finalPixel, { constrainRotation: info?.shiftKey });
4013
4530
  replaceWorkingPoints(preDragPoints);
4014
4531
  workingAmplifierPlacements = preDragAmplifierPlacements;
4015
- if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4016
- emitTransient("end", finalSnapshot.points, null);
4017
- commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
4532
+ const textOptionFold = isTextGraphic && (fsm.kind === "rotate" || fsm.kind === "scale");
4533
+ if (fsm.kind === "rotate" && !isTextGraphic) boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4534
+ if (textOptionFold) {
4535
+ workingOptions = foldTextTransform(preDragBox, finalSnapshot.box, fsm.kind, preDragOptions);
4536
+ nonGeometryDirty = true;
4537
+ }
4538
+ const committedPoints = textOptionFold ? preDragPoints : finalSnapshot.points;
4539
+ emitTransient("end", committedPoints, null);
4540
+ commitGesture(committedPoints, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
4018
4541
  return;
4019
4542
  }
4020
4543
  };
@@ -4185,7 +4708,13 @@ function renderPointSymbolForAdapter(adapter, capability, symbol) {
4185
4708
  svg: render.svg,
4186
4709
  width: render.width,
4187
4710
  height: render.height,
4188
- anchor: [render.anchor.x, render.anchor.y]
4711
+ anchor: [render.anchor.x, render.anchor.y],
4712
+ coreRect: {
4713
+ x: render.octagonAnchor.x - render.octagonSize / 2,
4714
+ y: render.octagonAnchor.y - render.octagonSize / 2,
4715
+ width: render.octagonSize,
4716
+ height: render.octagonSize
4717
+ }
4189
4718
  },
4190
4719
  feature: pointSymbolFeature(symbol, render, adapter.getResolution())
4191
4720
  };
@@ -4223,18 +4752,6 @@ function boundProperty(key, value) {
4223
4752
  //#region src/tactical-draw/transform-controller.ts
4224
4753
  /** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
4225
4754
  const MIN_POINT_SYMBOL_SIZE = .1;
4226
- /**
4227
- * The uniform scale factor and rotation delta a box implies relative to a
4228
- * pre-drag box, given the gesture kind. Body translate reports neither (1, 0);
4229
- * measures ignore this and take their geometry straight from the transformed
4230
- * anchor points — only point symbols consume it.
4231
- */
4232
- function boxScaleRotation(kind, box, preDragBox) {
4233
- return {
4234
- scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
4235
- rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
4236
- };
4237
- }
4238
4755
  /** Apply a box's scale/rotation deltas to each member's pre-drag point-symbol state. */
4239
4756
  function scalePointSymbolStates(pre, scale, rotation) {
4240
4757
  return pre.map((state) => state ? {
@@ -4444,6 +4961,40 @@ function runGroupTransform(opts) {
4444
4961
  }
4445
4962
  return pixels;
4446
4963
  }
4964
+ /** The rotated-text footprint corners for a Text member's rendered feature, or `null`. */
4965
+ function textFootprintCorners(features) {
4966
+ const box = fitTextFootprintFromFeatures(features, driver.toPixel, {
4967
+ resolution: adapter.getResolution(),
4968
+ bearing: adapter.getBearing()
4969
+ });
4970
+ return box ? boxCorners(box) : null;
4971
+ }
4972
+ /**
4973
+ * Fold a box gesture's delta (`box` vs the pre-drag box) into each measure
4974
+ * member that persists orientation/size as options (e.g. Text), off the
4975
+ * per-member `baseOverrides` so live ticks recompute rather than compound.
4976
+ * Only members whose kind returns a patch (Text) are touched — members that
4977
+ * carry an orientation *freeze* (Battle Position's echelon anchor) return no
4978
+ * patch and keep their live override untouched. Point-symbol members take the
4979
+ * delta through their own working state, not here.
4980
+ */
4981
+ function applyMeasureTransformForBox(box, fromBox, kind, baseOverrides) {
4982
+ const { scale, rotation } = boxScaleRotation(kind, box, fromBox);
4983
+ if (scale === 1 && rotation === 0) return;
4984
+ members.forEach((member, i) => {
4985
+ if (isPointSymbolMember(member)) return;
4986
+ const base = baseOverrides[i] ?? null;
4987
+ const effective = mergeOptions(member.graphic.options, base);
4988
+ const patch = applyBoxTransformOptions(member.graphic.kind, effective, {
4989
+ scale,
4990
+ rotationRadians: rotation
4991
+ });
4992
+ if (patch) member.optionsOverride = {
4993
+ ...base ?? {},
4994
+ ...patch
4995
+ };
4996
+ });
4997
+ }
4447
4998
  /** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
4448
4999
  function currentAngle() {
4449
5000
  return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
@@ -4483,8 +5034,9 @@ function runGroupTransform(opts) {
4483
5034
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
4484
5035
  if (footprint) boxPixels = footprint.corners;
4485
5036
  } else {
4486
- features = renderMeasureForAdapter(adapter, workingGraphicFor(i), renderOpts).features;
4487
- boxPixels = pixelsFromFeatures(features);
5037
+ const working = workingGraphicFor(i);
5038
+ features = renderMeasureForAdapter(adapter, working, renderOpts).features;
5039
+ boxPixels = (working.kind === "text" ? textFootprintCorners(features) : null) ?? pixelsFromFeatures(features);
4488
5040
  }
4489
5041
  } catch {
4490
5042
  features = [];
@@ -4883,6 +5435,7 @@ function runGroupTransform(opts) {
4883
5435
  activeBoxDrag.lastSnapshot = snapshot;
4884
5436
  applyConcatenatedPoints(snapshot.points);
4885
5437
  applyPointSymbolStateByMember(pointSymbolStateForBox(snapshot.box));
5438
+ applyMeasureTransformForBox(snapshot.box, activeBoxDrag.preDragBox, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptionsOverrideByMember);
4886
5439
  applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
4887
5440
  renderAllPreviews();
4888
5441
  renderHandlesLayer();
@@ -4922,6 +5475,7 @@ function runGroupTransform(opts) {
4922
5475
  applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
4923
5476
  if (members.some(isPointSymbolMember)) shell.suppressNextClickAway();
4924
5477
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
5478
+ applyMeasureTransformForBox(finalSnapshot.box, preDragBox, fsm.kind, preDragOptionsOverrideByMember);
4925
5479
  commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle, nextPointSymbolStateByMember);
4926
5480
  };
4927
5481
  const core = runEditInteraction({
@@ -5913,7 +6467,11 @@ var TacticalDraw = class {
5913
6467
  * then `previewLayer`, then `graphicsLayer` — against each layer's current
5914
6468
  * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
5915
6469
  * excluded: it is non-interactive draw-guide scaffolding, never a
5916
- * legitimate "owned" click target.
6470
+ * legitimate "owned" click target. Within each layer, candidates follow
6471
+ * the shared visible-intent ranking (direct marks, then filled interiors,
6472
+ * then inferred outline-only reach, distance within direct marks, then
6473
+ * visual topmost order) — this explicit-affordance layer order is the
6474
+ * tier above all of it.
5917
6475
  *
5918
6476
  * `preview` and `graphics` each get a label text-extent fallback after the
5919
6477
  * geometry pass misses on that layer: for every `Point` feature carrying a
@@ -5924,7 +6482,7 @@ var TacticalDraw = class {
5924
6482
  * This matters most for the active preview, since a click can land
5925
6483
  * anywhere across a rotated/offset label during an edit.
5926
6484
  *
5927
- * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
6485
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
5928
6486
  * pass the host's raw click event as `originalEvent` to get the same
5929
6487
  * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
5930
6488
  * pass an explicit `tolerance` to override it outright.
@@ -5940,7 +6498,7 @@ var TacticalDraw = class {
5940
6498
  */
5941
6499
  ownsInteractionAt(pixel, options) {
5942
6500
  this.assertAlive();
5943
- const tolerance = options?.tolerance ?? mapEventHitTolerance(options?.originalEvent);
6501
+ const tolerance = options?.tolerance ?? pickHitTolerance(options?.originalEvent);
5944
6502
  const toPixel = (lonLat) => this.adapter.getPixelFromCoordinate(lonLat);
5945
6503
  const layers = [
5946
6504
  {
@@ -5959,20 +6517,48 @@ var TacticalDraw = class {
5959
6517
  labelFallback: true
5960
6518
  }
5961
6519
  ];
6520
+ const size = this.adapter.getViewportSize();
6521
+ const context = {
6522
+ pixel,
6523
+ pointerType: pickPointerType(options?.originalEvent),
6524
+ tolerance,
6525
+ toPixel,
6526
+ viewport: {
6527
+ minX: 0,
6528
+ minY: 0,
6529
+ maxX: size.width,
6530
+ maxY: size.height
6531
+ },
6532
+ bearing: this.adapter.getBearing()
6533
+ };
5962
6534
  for (const { layer, layerId, labelFallback } of layers) {
5963
6535
  const features = this.adapter.getLayerFeatures(layerId);
5964
- const hit = hitTestFeature(pixel, features, tolerance, toPixel);
5965
- if (hit) return this.toInteractionHit(layer, hit);
5966
- if (labelFallback) for (const feature of features) {
5967
- if (feature.geometry?.type !== "Point") continue;
5968
- if (typeof feature.properties?.text !== "string") continue;
5969
- const anchorPixel = toPixel(feature.geometry.coordinates);
5970
- if (!anchorPixel) continue;
5971
- const box = fitLabelBoxFromFeature(feature, anchorPixel, {
5972
- resolution: this.adapter.getResolution(),
5973
- bearing: this.adapter.getBearing()
5974
- });
5975
- if (box && pointInBox(box, pixel)) return this.toInteractionHit(layer, feature);
6536
+ const geometryCandidate = evaluatePickCandidates(features.map((feature, index) => ({
6537
+ feature,
6538
+ visualOrder: renderOrderToVisualOrder(index, features.length)
6539
+ })), context);
6540
+ if (geometryCandidate) return this.toInteractionHit(layer, geometryCandidate.feature);
6541
+ if (labelFallback) {
6542
+ const labelCandidates = [];
6543
+ for (const feature of features) {
6544
+ if (feature.geometry?.type !== "Point") continue;
6545
+ if (typeof feature.properties?.text !== "string") continue;
6546
+ const anchorPixel = toPixel(feature.geometry.coordinates);
6547
+ if (!anchorPixel) continue;
6548
+ const box = fitLabelBoxFromFeature(feature, anchorPixel, {
6549
+ resolution: this.adapter.getResolution(),
6550
+ bearing: this.adapter.getBearing()
6551
+ });
6552
+ if (box) labelCandidates.push({
6553
+ feature,
6554
+ labelExtent: box
6555
+ });
6556
+ }
6557
+ const labelCandidate = evaluatePickCandidates(labelCandidates.map((candidate, index) => ({
6558
+ ...candidate,
6559
+ visualOrder: renderOrderToVisualOrder(index, labelCandidates.length)
6560
+ })), context);
6561
+ if (labelCandidate) return this.toInteractionHit(layer, labelCandidate.feature);
5976
6562
  }
5977
6563
  }
5978
6564
  return null;
@@ -6390,4 +6976,4 @@ function nearestWorldCopyPosition(position, refLng) {
6390
6976
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
6391
6977
  }
6392
6978
  //#endregion
6393
- export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
6979
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextSizePixels, tryControlMeasureIdFromFeature };