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

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.d.mts CHANGED
@@ -474,6 +474,38 @@ declare const DEFAULT_TEXT_SIZE_MAX_PIXELS = 24;
474
474
  * clamp inside its per-zoom-stop style expression.
475
475
  */
476
476
  declare function resolveGroundTextSizePixels(textSizeMeters: number, resolution: number, text?: Pick<TextStyleOptions, "textSizeMinPixels" | "textSizeMaxPixels">): number;
477
+ /**
478
+ * The size properties a Text graphic's feature carries (see `text.ts` /
479
+ * `FeaturePartProps`). Mutually exclusive size anchors: `textSizePixels`
480
+ * (screen-anchored) or `textSizeMeters` (ground-anchored). `textMaxSizePixels`
481
+ * is the ADR-0037 hide threshold; the min/max band fields are the amplifier
482
+ * -label clamp bounds, applied only when no max-size threshold is present.
483
+ */
484
+ interface TextSizeProps {
485
+ textSizeMeters?: number;
486
+ textSizePixels?: number;
487
+ textMaxSizePixels?: number;
488
+ textSizeMinPixels?: number;
489
+ textSizeMaxPixels?: number;
490
+ }
491
+ /**
492
+ * Resolve a Text graphic's on-screen size in pixels, honoring the
493
+ * `textMaxSizePixels` hide threshold (ADR-0037). Returns `null` — the "hidden"
494
+ * signal — when the graphic should render no text this frame; adapters emit no
495
+ * text style for a `null`. Distinct from [[resolveGroundTextSizePixels]], which
496
+ * keeps the 8–24 px clamp band for amplifier labels: this helper is the Text
497
+ * graphic's own sizing rule.
498
+ *
499
+ * - screen-anchored (`textSizePixels`): the fixed pixel size, never scaled or
500
+ * hidden by zoom.
501
+ * - ground-anchored (`textSizeMeters`) with a `textMaxSizePixels` threshold:
502
+ * *unclamped* `meters / resolution` (the 8–24 band does NOT apply), hidden
503
+ * (`null`) once that exceeds the threshold — text shrinks freely zooming out
504
+ * and vanishes rather than clamps zooming in.
505
+ * - ground-anchored without a threshold: falls back to the clamped band.
506
+ * - no usable size or resolution: `null`.
507
+ */
508
+ declare function resolveTextSizePixels(props: TextSizeProps, resolution: number): number | null;
477
509
  //#endregion
478
510
  //#region src/gestures/hit-test.d.ts
479
511
  type ToPixel = (lonLat: Position) => PixelCoordinate | null;
@@ -2031,4 +2063,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
2031
2063
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
2032
2064
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
2033
2065
  //#endregion
2034
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
2066
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextSizeProps, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, resolveTextSizePixels, tryControlMeasureIdFromFeature };
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.
@@ -296,6 +296,33 @@ function resolveGroundTextSizePixels(textSizeMeters, resolution, text) {
296
296
  const max = text?.textSizeMaxPixels ?? 24;
297
297
  return Math.min(max, Math.max(min, textSizeMeters / resolution));
298
298
  }
299
+ /**
300
+ * Resolve a Text graphic's on-screen size in pixels, honoring the
301
+ * `textMaxSizePixels` hide threshold (ADR-0037). Returns `null` — the "hidden"
302
+ * signal — when the graphic should render no text this frame; adapters emit no
303
+ * text style for a `null`. Distinct from [[resolveGroundTextSizePixels]], which
304
+ * keeps the 8–24 px clamp band for amplifier labels: this helper is the Text
305
+ * graphic's own sizing rule.
306
+ *
307
+ * - screen-anchored (`textSizePixels`): the fixed pixel size, never scaled or
308
+ * hidden by zoom.
309
+ * - ground-anchored (`textSizeMeters`) with a `textMaxSizePixels` threshold:
310
+ * *unclamped* `meters / resolution` (the 8–24 band does NOT apply), hidden
311
+ * (`null`) once that exceeds the threshold — text shrinks freely zooming out
312
+ * and vanishes rather than clamps zooming in.
313
+ * - ground-anchored without a threshold: falls back to the clamped band.
314
+ * - no usable size or resolution: `null`.
315
+ */
316
+ function resolveTextSizePixels(props, resolution) {
317
+ if (typeof props.textSizePixels === "number") return props.textSizePixels;
318
+ const meters = props.textSizeMeters;
319
+ if (typeof meters !== "number" || !(resolution > 0)) return null;
320
+ if (typeof props.textMaxSizePixels === "number") {
321
+ const px = meters / resolution;
322
+ return px > props.textMaxSizePixels ? null : px;
323
+ }
324
+ return resolveGroundTextSizePixels(meters, resolution, props);
325
+ }
299
326
  //#endregion
300
327
  //#region src/gestures/hit-test.ts
301
328
  /** Clamped parameter t ∈ [0, 1] of `pixel` projected onto the segment a→b in pixel space, or null for a zero-length segment. */
@@ -684,6 +711,10 @@ const PIXEL_SIZE_OPTIONS = {
684
711
  "antitank-wall": {
685
712
  pixels: "toothHeightPixels",
686
713
  meters: "toothHeight"
714
+ },
715
+ text: {
716
+ pixels: "sizePixels",
717
+ meters: "sizeMeters"
687
718
  }
688
719
  };
689
720
  /** The shared label-size pair every `capturesLabelSize` kind carries (ADR-0028). */
@@ -745,6 +776,34 @@ function getSizeAnchor(measure) {
745
776
  return measureUsesAdapterResolution(measure) ? "screen" : "ground";
746
777
  }
747
778
  /**
779
+ * Materialise a *pixel-authored* kind's default pixel size onto a measure that
780
+ * carries neither size key, so the ADR-0020 ground bake has a pixel value to
781
+ * resolve to meters at commit. A kind is pixel-authored when its default
782
+ * options declare the pixel-size option but no meter counterpart (e.g. Text's
783
+ * `sizePixels: 24` with no `sizeMeters`) — such a fresh draw is authored in
784
+ * pixels and must bake to meters, but a default draw commits with no options,
785
+ * so the pixel size would otherwise never land and the graphic would persist
786
+ * screen-anchored forever. No-op when the measure already carries a size key,
787
+ * the kind has no pixel-size option, or the kind's default is meter-denominated
788
+ * (e.g. FLOT, whose meter default governs a default draw without any bake) —
789
+ * so this never changes the committed size of the pre-existing pixel kinds.
790
+ */
791
+ function withDefaultAuthoringSize(measure) {
792
+ const spec = PIXEL_SIZE_OPTIONS[measure.kind];
793
+ if (!spec) return measure;
794
+ const options = measure.options ?? {};
795
+ if (options[spec.pixels] !== void 0 || options[spec.meters] !== void 0) return measure;
796
+ const defaults = getDefaultOptions(measure.kind);
797
+ if (defaults[spec.meters] !== void 0 || defaults[spec.pixels] === void 0) return measure;
798
+ return {
799
+ ...measure,
800
+ options: {
801
+ ...options,
802
+ [spec.pixels]: defaults[spec.pixels]
803
+ }
804
+ };
805
+ }
806
+ /**
748
807
  * Bake a screen-anchored (pixel) size to a ground-anchored (meter) size at the
749
808
  * adapter's current resolution, then strip the pixel + `metersPerPixel` keys so
750
809
  * the result is an ordinary meters graphic carrying no pixel intent — afterward
@@ -1535,7 +1594,7 @@ function runFixedLengthDraw(options) {
1535
1594
  }
1536
1595
  function buildSnapshot() {
1537
1596
  const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
1538
- const captured = withCommittedAdapterMeasureOptions({
1597
+ const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
1539
1598
  id: generateId(),
1540
1599
  kind,
1541
1600
  controlPoints: finalPoints,
@@ -1543,8 +1602,8 @@ function runFixedLengthDraw(options) {
1543
1602
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
1544
1603
  ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1545
1604
  ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1546
- });
1547
- const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
1605
+ }));
1606
+ const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
1548
1607
  return {
1549
1608
  graphic: cloneControlMeasure(measure),
1550
1609
  render: renderMeasureForAdapter(adapter, measure, renderOpts)
@@ -1815,7 +1874,7 @@ function runVariableLengthDraw(options) {
1815
1874
  renderPreview();
1816
1875
  }
1817
1876
  function buildSnapshot() {
1818
- const built = withCommittedAdapterMeasureOptions({
1877
+ const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
1819
1878
  id: generateId(),
1820
1879
  kind,
1821
1880
  controlPoints: committed.map((p) => [...p]),
@@ -1823,8 +1882,8 @@ function runVariableLengthDraw(options) {
1823
1882
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
1824
1883
  ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1825
1884
  ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1826
- });
1827
- const measure = sizeAnchor === "screen" ? built : bakePixelSizeToGround(built, adapter);
1885
+ }));
1886
+ const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
1828
1887
  return {
1829
1888
  graphic: cloneControlMeasure(measure),
1830
1889
  render: renderMeasureForAdapter(adapter, measure, renderOpts)
@@ -2182,6 +2241,21 @@ function normalizeRotation(angle) {
2182
2241
  function constrainRotation(angle) {
2183
2242
  return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
2184
2243
  }
2244
+ //#endregion
2245
+ //#region src/tactical-draw/transform-box.ts
2246
+ /**
2247
+ * The uniform scale factor and rotation delta a box implies relative to a
2248
+ * pre-drag box, given the gesture kind. A `body` translate reports neither
2249
+ * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2250
+ * (folding the delta into rotation/size options); the delta is pivot-
2251
+ * independent so either can apply it without knowing the box center.
2252
+ */
2253
+ function boxScaleRotation(kind, box, preDragBox) {
2254
+ return {
2255
+ scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2256
+ rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2257
+ };
2258
+ }
2185
2259
  function rotate(x, y, angle) {
2186
2260
  const cos = Math.cos(angle);
2187
2261
  const sin = Math.sin(angle);
@@ -2720,12 +2794,16 @@ function normalizeAngle(angle) {
2720
2794
  * grows away from the anchor) or local −x for `"end"` (text grows back
2721
2795
  * toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
2722
2796
  * screen (y-down) convention this offset needs.
2797
+ * - **Hidden**: a ground-anchored size past `textMaxSizePixels` (ADR-0037)
2798
+ * returns `null` — the box tracks `resolveTextSizePixels`'s hide rule
2799
+ * rather than keep boxing text that no adapter is drawing.
2723
2800
  */
2724
2801
  function fitLabelTransformBox(input) {
2725
- const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
2802
+ const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing, unclamped, textMaxSizePixels } = input;
2726
2803
  if (!text) return null;
2804
+ if (typeof textMaxSizePixels === "number" && textSizePixels === void 0 && typeof textSizeMeters === "number" && typeof resolution === "number" && resolution > 0 && textSizeMeters / resolution > textMaxSizePixels) return null;
2727
2805
  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;
2806
+ 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
2807
  const lines = text.split("\n");
2730
2808
  const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
2731
2809
  const height = lines.length * LINE_HEIGHT_FACTOR * h;
@@ -2760,12 +2838,47 @@ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
2760
2838
  textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
2761
2839
  textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
2762
2840
  textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
2841
+ textMaxSizePixels: typeof props.textMaxSizePixels === "number" ? props.textMaxSizePixels : void 0,
2763
2842
  anchor: anchorPixel,
2764
2843
  resolution: view.resolution,
2765
- bearing: view.bearing
2844
+ bearing: view.bearing,
2845
+ unclamped: view.unclamped
2766
2846
  });
2767
2847
  }
2768
2848
  /**
2849
+ * Fit a `TransformBox` around a rendered **Text generic graphic** feature — the
2850
+ * Text analogue of the point-symbol footprint (ADR-0031): a rotated logical
2851
+ * rectangle centered on / shifted from the anchor per `textAlign` (via the
2852
+ * feature's `textAnchor` prop), sized from the text's line count and longest
2853
+ * line at the actual rendered height (screen `textSizePixels` as-is; ground
2854
+ * `textSizeMeters / resolution` unclamped). Drives the Text edit/transform box
2855
+ * outline, its corner-scale handles, and the pick extent, and updates live as a
2856
+ * rotate/scale gesture folds new rotation/size into the feature. Returns `null`
2857
+ * when the feature carries no text. Rotation pivots at the anchor, so the box
2858
+ * orbits the anchor when the text rotates — matching how the adapters render a
2859
+ * `textAnchor`-shifted, rotated label.
2860
+ */
2861
+ function fitTextGraphicFootprint(feature, anchorPixel, view) {
2862
+ return fitLabelBoxFromFeature(feature, anchorPixel, {
2863
+ ...view,
2864
+ unclamped: true
2865
+ });
2866
+ }
2867
+ /**
2868
+ * Locate the single Text `Point` feature in `features`, project its anchor, and
2869
+ * return its {@link fitTextGraphicFootprint} box — the shared body behind the
2870
+ * edit controller's Text box and the group transform-controller's Text member
2871
+ * footprint. Returns `null` when there is no text feature or its anchor is
2872
+ * off-screen.
2873
+ */
2874
+ function fitTextFootprintFromFeatures(features, toPixel, view) {
2875
+ const f = features.find((feat) => feat.geometry?.type === "Point" && typeof feat.properties?.text === "string");
2876
+ if (!f || f.geometry?.type !== "Point") return null;
2877
+ const anchorPixel = toPixel(f.geometry.coordinates);
2878
+ if (!anchorPixel) return null;
2879
+ return fitTextGraphicFootprint(f, anchorPixel, view);
2880
+ }
2881
+ /**
2769
2882
  * Carry user-adjusted label anchors (and rotation overrides) through a
2770
2883
  * transform-box gesture. Anchors are transformed in pixel space alongside the
2771
2884
  * control points so the result remains visually rigid across map
@@ -3123,6 +3236,7 @@ function runEdit(opts) {
3123
3236
  const closedRing = rule?.closedRing ?? false;
3124
3237
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
3125
3238
  const isAreaKind = metadata.geometry === "area";
3239
+ const isTextGraphic = measure.kind === "text";
3126
3240
  const requestedModes = normaliseModes(initialModes);
3127
3241
  const editStartPoints = measure.controlPoints.map((p) => [...p]);
3128
3242
  const workingPoints = editStartPoints.map((p) => [...p]);
@@ -3252,6 +3366,7 @@ function runEdit(opts) {
3252
3366
  * projects (e.g. the working copy renders no features this frame).
3253
3367
  */
3254
3368
  function fitBoxFromFeatures(features) {
3369
+ if (isTextGraphic) return fitTextBox(features);
3255
3370
  const positions = [];
3256
3371
  for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
3257
3372
  const pixels = [];
@@ -3262,13 +3377,28 @@ function runEdit(opts) {
3262
3377
  return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
3263
3378
  }
3264
3379
  /**
3380
+ * The Text footprint box (rotated rectangle hugging the rendered text),
3381
+ * derived from the single Point feature's text props. Unlike geometry-fit
3382
+ * boxes it carries the text's own on-screen angle (so it tilts with the
3383
+ * rotation option) and honors `textAnchor` alignment; the session `boxAngle`
3384
+ * is not used for Text.
3385
+ */
3386
+ function fitTextBox(features) {
3387
+ return fitTextFootprintFromFeatures(features, driver.toPixel, {
3388
+ resolution: adapter.getResolution(),
3389
+ bearing: adapter.getBearing()
3390
+ });
3391
+ }
3392
+ /**
3265
3393
  * Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
3266
3394
  * in flight — that drag paints the box rigidly from its own snapshot
3267
3395
  * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
3268
- * doesn't fight its own paint.
3396
+ * doesn't fight its own paint. Text is the exception: its box follows the
3397
+ * rendered footprint live (a rotate/scale gesture changes the rendered text),
3398
+ * so it re-fits every frame, drag included.
3269
3399
  */
3270
3400
  function refitBox() {
3271
- if (activeBoxDrag) return;
3401
+ if (activeBoxDrag && !isTextGraphic) return;
3272
3402
  box = fitBoxFromFeatures(lastPreviewFeatures);
3273
3403
  }
3274
3404
  function liftOriginalFromGraphics() {
@@ -3319,7 +3449,7 @@ function runEdit(opts) {
3319
3449
  }), handleIdPrefix, interactionStyle);
3320
3450
  if (modes.includes("labeldrag")) features.push(...labels.handleFeatures());
3321
3451
  if (modes.includes("transform")) {
3322
- const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
3452
+ const liveBox = activeBoxDrag && !isTextGraphic ? activeBoxDrag.lastSnapshot.box : box;
3323
3453
  if (liveBox) {
3324
3454
  features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
3325
3455
  features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
@@ -3328,6 +3458,19 @@ function runEdit(opts) {
3328
3458
  ctx.writeLayer(handlesLayer, features);
3329
3459
  }
3330
3460
  /**
3461
+ * Repaint the preview and, in lockstep, the handles. `renderPreview` re-fits
3462
+ * `box` (for Text, the footprint depends on options — size/rotation/
3463
+ * alignment/text), so the transform-box outline and its corner/rotate handles
3464
+ * must repaint to match; otherwise they stay where the old options placed them
3465
+ * and the pick target (`box`) drifts out from under the visible handles
3466
+ * (#scale-handle desync). Used by the non-geometry option/style/amplifier
3467
+ * setters, which change what the preview renders without moving a point.
3468
+ */
3469
+ function renderPreviewAndHandles() {
3470
+ renderPreview();
3471
+ renderHandlesLayer();
3472
+ }
3473
+ /**
3331
3474
  * Builds the committed snapshot against explicit `options` rather than the
3332
3475
  * live `workingOptions` — used to render a gesture's "previous" snapshot
3333
3476
  * against pre-freeze options when a rotate has already merged its
@@ -3472,7 +3615,7 @@ function runEdit(opts) {
3472
3615
  replaceWorkingOptions(previousOptions);
3473
3616
  throw err;
3474
3617
  }
3475
- renderPreview();
3618
+ renderPreviewAndHandles();
3476
3619
  nonGeometryDirty = true;
3477
3620
  dirty = true;
3478
3621
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
@@ -3498,7 +3641,7 @@ function runEdit(opts) {
3498
3641
  currentStyle = previousStyle;
3499
3642
  throw err;
3500
3643
  }
3501
- renderPreview();
3644
+ renderPreviewAndHandles();
3502
3645
  nonGeometryDirty = true;
3503
3646
  dirty = true;
3504
3647
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
@@ -3544,7 +3687,7 @@ function runEdit(opts) {
3544
3687
  workingTextAmplifiers = previousTextAmplifiers;
3545
3688
  throw err;
3546
3689
  }
3547
- renderPreview();
3690
+ renderPreviewAndHandles();
3548
3691
  nonGeometryDirty = true;
3549
3692
  dirty = true;
3550
3693
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
@@ -3571,7 +3714,7 @@ function runEdit(opts) {
3571
3714
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
3572
3715
  if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
3573
3716
  const currentRendered = rebuildRendered();
3574
- renderPreview();
3717
+ renderPreviewAndHandles();
3575
3718
  nonGeometryDirty = true;
3576
3719
  dirty = true;
3577
3720
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
@@ -3917,11 +4060,40 @@ function runEdit(opts) {
3917
4060
  recomputeDirty();
3918
4061
  emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
3919
4062
  }
4063
+ /**
4064
+ * Fold a box gesture's delta (the box `to` relative to `from`, given the
4065
+ * gesture `kind`) into a Text graphic's `rotation`/size options, on top of
4066
+ * `base`. A `scale` grows the active size anchor; a `rotate` adds to the
4067
+ * rotation. Returns `base` unchanged when the kind produces no patch. The
4068
+ * delta (uniform scale ratio, angle difference) is pivot-independent, so
4069
+ * folding it keeps the anchor point fixed while the glyph turns/grows.
4070
+ */
4071
+ function foldTextTransform(from, to, kind, base) {
4072
+ const { scale, rotation } = boxScaleRotation(kind, to, from);
4073
+ const patch = applyBoxTransformOptions(measure.kind, base ?? {}, {
4074
+ scale,
4075
+ rotationRadians: rotation
4076
+ });
4077
+ return patch ? {
4078
+ ...base ?? {},
4079
+ ...patch
4080
+ } : base;
4081
+ }
3920
4082
  function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
3921
4083
  if (!activeBoxDrag) return;
3922
4084
  const resolvedPixel = translatedBoxPixel(pixel, info, activeBoxDrag);
3923
4085
  const snapshot = activeBoxDrag.fsm.move(resolvedPixel, { constrainRotation });
3924
4086
  activeBoxDrag.lastSnapshot = snapshot;
4087
+ if (isTextGraphic && (activeBoxDrag.fsm.kind === "rotate" || activeBoxDrag.fsm.kind === "scale")) {
4088
+ workingPoints.splice(0, workingPoints.length, ...activeBoxDrag.preDragPoints);
4089
+ workingOptions = foldTextTransform(activeBoxDrag.preDragBox, snapshot.box, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptions);
4090
+ nonGeometryDirty = true;
4091
+ renderPreview();
4092
+ renderHandlesLayer();
4093
+ recomputeDirty();
4094
+ emitTransient("move", activeBoxDrag.preDragPoints, null);
4095
+ return;
4096
+ }
3925
4097
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3926
4098
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
3927
4099
  renderPreview();
@@ -4012,9 +4184,15 @@ function runEdit(opts) {
4012
4184
  const finalSnapshot = fsm.move(finalPixel, { constrainRotation: info?.shiftKey });
4013
4185
  replaceWorkingPoints(preDragPoints);
4014
4186
  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);
4187
+ const textOptionFold = isTextGraphic && (fsm.kind === "rotate" || fsm.kind === "scale");
4188
+ if (fsm.kind === "rotate" && !isTextGraphic) boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4189
+ if (textOptionFold) {
4190
+ workingOptions = foldTextTransform(preDragBox, finalSnapshot.box, fsm.kind, preDragOptions);
4191
+ nonGeometryDirty = true;
4192
+ }
4193
+ const committedPoints = textOptionFold ? preDragPoints : finalSnapshot.points;
4194
+ emitTransient("end", committedPoints, null);
4195
+ commitGesture(committedPoints, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
4018
4196
  return;
4019
4197
  }
4020
4198
  };
@@ -4223,18 +4401,6 @@ function boundProperty(key, value) {
4223
4401
  //#region src/tactical-draw/transform-controller.ts
4224
4402
  /** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
4225
4403
  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
4404
  /** Apply a box's scale/rotation deltas to each member's pre-drag point-symbol state. */
4239
4405
  function scalePointSymbolStates(pre, scale, rotation) {
4240
4406
  return pre.map((state) => state ? {
@@ -4444,6 +4610,40 @@ function runGroupTransform(opts) {
4444
4610
  }
4445
4611
  return pixels;
4446
4612
  }
4613
+ /** The rotated-text footprint corners for a Text member's rendered feature, or `null`. */
4614
+ function textFootprintCorners(features) {
4615
+ const box = fitTextFootprintFromFeatures(features, driver.toPixel, {
4616
+ resolution: adapter.getResolution(),
4617
+ bearing: adapter.getBearing()
4618
+ });
4619
+ return box ? boxCorners(box) : null;
4620
+ }
4621
+ /**
4622
+ * Fold a box gesture's delta (`box` vs the pre-drag box) into each measure
4623
+ * member that persists orientation/size as options (e.g. Text), off the
4624
+ * per-member `baseOverrides` so live ticks recompute rather than compound.
4625
+ * Only members whose kind returns a patch (Text) are touched — members that
4626
+ * carry an orientation *freeze* (Battle Position's echelon anchor) return no
4627
+ * patch and keep their live override untouched. Point-symbol members take the
4628
+ * delta through their own working state, not here.
4629
+ */
4630
+ function applyMeasureTransformForBox(box, fromBox, kind, baseOverrides) {
4631
+ const { scale, rotation } = boxScaleRotation(kind, box, fromBox);
4632
+ if (scale === 1 && rotation === 0) return;
4633
+ members.forEach((member, i) => {
4634
+ if (isPointSymbolMember(member)) return;
4635
+ const base = baseOverrides[i] ?? null;
4636
+ const effective = mergeOptions(member.graphic.options, base);
4637
+ const patch = applyBoxTransformOptions(member.graphic.kind, effective, {
4638
+ scale,
4639
+ rotationRadians: rotation
4640
+ });
4641
+ if (patch) member.optionsOverride = {
4642
+ ...base ?? {},
4643
+ ...patch
4644
+ };
4645
+ });
4646
+ }
4447
4647
  /** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
4448
4648
  function currentAngle() {
4449
4649
  return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
@@ -4483,8 +4683,9 @@ function runGroupTransform(opts) {
4483
4683
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
4484
4684
  if (footprint) boxPixels = footprint.corners;
4485
4685
  } else {
4486
- features = renderMeasureForAdapter(adapter, workingGraphicFor(i), renderOpts).features;
4487
- boxPixels = pixelsFromFeatures(features);
4686
+ const working = workingGraphicFor(i);
4687
+ features = renderMeasureForAdapter(adapter, working, renderOpts).features;
4688
+ boxPixels = (working.kind === "text" ? textFootprintCorners(features) : null) ?? pixelsFromFeatures(features);
4488
4689
  }
4489
4690
  } catch {
4490
4691
  features = [];
@@ -4883,6 +5084,7 @@ function runGroupTransform(opts) {
4883
5084
  activeBoxDrag.lastSnapshot = snapshot;
4884
5085
  applyConcatenatedPoints(snapshot.points);
4885
5086
  applyPointSymbolStateByMember(pointSymbolStateForBox(snapshot.box));
5087
+ applyMeasureTransformForBox(snapshot.box, activeBoxDrag.preDragBox, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptionsOverrideByMember);
4886
5088
  applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
4887
5089
  renderAllPreviews();
4888
5090
  renderHandlesLayer();
@@ -4922,6 +5124,7 @@ function runGroupTransform(opts) {
4922
5124
  applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
4923
5125
  if (members.some(isPointSymbolMember)) shell.suppressNextClickAway();
4924
5126
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
5127
+ applyMeasureTransformForBox(finalSnapshot.box, preDragBox, fsm.kind, preDragOptionsOverrideByMember);
4925
5128
  commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle, nextPointSymbolStateByMember);
4926
5129
  };
4927
5130
  const core = runEditInteraction({
@@ -6390,4 +6593,4 @@ function nearestWorldCopyPosition(position, refLng) {
6390
6593
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
6391
6594
  }
6392
6595
  //#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 };
6596
+ 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, resolveTextSizePixels, tryControlMeasureIdFromFeature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.30",
3
+ "version": "0.2.0-alpha.31",
4
4
  "description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@orbat-mapper/control-measures": "^0.2.0-alpha.22"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.24"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",