@orbat-mapper/tactical-draw 0.7.0 → 0.8.1

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
@@ -1,4 +1,4 @@
1
- import { C as EditPointerDriver, S as makeFillPatternImage, T as PointerEventInfo, _ as HatchPattern, a as LayerId, b as fillPatternImageId, c as MapEvent, d as PickHandler, f as PickOptions, g as HATCH_PATTERNS, h as TextStyleOptions, i as ImageOverlayResource, l as MapEventHandler, m as StyleOptions, n as HandleStyle, o as MapAdapter, p as PixelCoordinate, r as IconResource, s as MapCursor, t as FeatureGroup, u as PickEvent, v as MINE_PATTERN_TILE_SIZE, w as PointerCallback, x as isMinePattern, y as fillPatternImageDimension } from "./map-adapter-BWvKku2P.mjs";
1
+ import { C as EditPointerDriver, S as makeFillPatternImage, T as PointerEventInfo, _ as HatchPattern, a as LayerId, b as fillPatternImageId, c as MapEvent, d as PickHandler, f as PickOptions, g as HATCH_PATTERNS, h as TextStyleOptions, i as ImageOverlayResource, l as MapEventHandler, m as StyleOptions, n as HandleStyle, o as MapAdapter, p as PixelCoordinate, r as IconResource, s as MapCursor, t as FeatureGroup, u as PickEvent, v as MINE_PATTERN_TILE_SIZE, w as PointerCallback, x as isMinePattern, y as fillPatternImageDimension } from "./map-adapter-CyVIAoPf.mjs";
2
2
  import { AmplifierPlacement, AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureRender, ControlMeasureStyle, SymbolColors, TextAmplifiers } from "@orbat-mapper/control-measures";
3
3
  import { Feature, Position } from "geojson";
4
4
 
@@ -354,11 +354,42 @@ interface PointSymbolStyle {
354
354
  colors?: PointSymbolColors;
355
355
  monoColor?: string;
356
356
  opacity?: number;
357
+ /** 0–1 multiplier applied to both body and connector opacity; defaults to 1. */
358
+ overallOpacity?: number;
357
359
  outlineWidth?: number;
358
360
  fill?: boolean;
359
361
  frame?: boolean;
360
362
  icon?: boolean;
361
363
  }
364
+ type PointSymbolPlacement = {
365
+ mode: "screen-relative";
366
+ offset: [number, number];
367
+ } | {
368
+ mode: "ground";
369
+ position: Position;
370
+ };
371
+ /** Modifier that temporarily activates offset placement for a body drag. */
372
+ type OffsetDragModifier = "alt" | "shift" | "ctrl" | "meta";
373
+ type PointSymbolConnector = "auto" | "direct";
374
+ /** Optional connector overrides; omitted properties inherit symbol styling. */
375
+ interface PointSymbolOffsetStyle {
376
+ strokeColor?: string;
377
+ /** 0–1, independent of the body opacity. */
378
+ opacity?: number;
379
+ /** Fixed CSS-pixel width, independent of symbol size. */
380
+ strokeWidth?: number;
381
+ /** Alternating dash/gap lengths in CSS pixels; [] means solid. */
382
+ strokeDash?: number[];
383
+ }
384
+ interface PointSymbolOffset {
385
+ /** Gap at the symbol attachment end, measured along the line in CSS pixels; defaults to 0. */
386
+ anchorPadding?: number;
387
+ style?: PointSymbolOffsetStyle;
388
+ placement: PointSymbolPlacement;
389
+ connector: PointSymbolConnector;
390
+ /** Authored indicator length as a multiple of the displayed octagon size. */
391
+ indicatorLength?: number;
392
+ }
362
393
  interface PointSymbol {
363
394
  id: string;
364
395
  kind: "point-symbol";
@@ -368,6 +399,7 @@ interface PointSymbol {
368
399
  rotation: number;
369
400
  /** Milsymbol's octagon-based L dimension. */
370
401
  size: PointSymbolSize;
402
+ offset?: PointSymbolOffset;
371
403
  textAmplifiers?: TextAmplifiers;
372
404
  graphicModifiers?: PointSymbolGraphicModifiers;
373
405
  style?: PointSymbolStyle;
@@ -394,6 +426,19 @@ interface PointSymbolRender {
394
426
  octagonAnchor: PointSymbolAnchor;
395
427
  valid: boolean;
396
428
  validation?: unknown;
429
+ /** Optional body/attachment capability. All coordinates are canonical SVG units. */
430
+ offset?: {
431
+ body: PointSymbolRender; /** Body registration in the ordinary image, preserving natural HQ placement. */
432
+ ordinaryRegistration: PointSymbolAnchor;
433
+ attachment: PointSymbolAnchor;
434
+ indicator?: {
435
+ direction: PointSymbolAnchor;
436
+ length: number;
437
+ minLength?: number;
438
+ };
439
+ strokeColor: string;
440
+ strokeWidth: number;
441
+ };
397
442
  }
398
443
  interface PointSymbolCapability {
399
444
  readonly defaultSize: PointSymbolSize;
@@ -616,7 +661,7 @@ interface InteractionStyle {
616
661
  * remove) but no midpoints or box, and the edit controller deletes the tapped
617
662
  * vertex instead of dragging it.
618
663
  */
619
- type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
664
+ type EditMode = "reshape" | "transform" | "labeldrag" | "delete" | "offset";
620
665
  //#endregion
621
666
  //#region src/tactical-draw/session-events.d.ts
622
667
  /** A gesture's phase within a stream of {@link TransientChangeEvent}s. */
@@ -895,7 +940,63 @@ interface EditChangeEvent {
895
940
  abort(): void;
896
941
  }
897
942
  //#endregion
943
+ //#region src/point-symbol-offset.d.ts
944
+ /** Read-only layout needs forward projection only. Coordinates are CSS pixels. */
945
+ type PointSymbolProjection = Pick<MapAdapter, "getPixelFromCoordinate" | "getBearing" | "getResolution">;
946
+ /** Editing may also require inverse projection (notably ground placement). */
947
+ type PointSymbolView = Pick<MapAdapter, "getPixelFromCoordinate" | "getCoordinateFromPixel" | "getBearing" | "getResolution">;
948
+ interface PointSymbolOffsetLayout {
949
+ /** Selected ordinary or detached body render, with canonical SVG dimensions. */
950
+ render: PointSymbolRender;
951
+ /** Selected body resource; dimensions and anchor remain in canonical SVG units. */
952
+ resource: IconResource;
953
+ /** Body opacity after applying the overall multiplier. */
954
+ bodyOpacity: number;
955
+ /** Clockwise radians in the projection plane. */
956
+ rotation: number;
957
+ actual: PixelCoordinate;
958
+ /** Body registration, including natural HQ displacement in ordinary placement. */
959
+ display: PixelCoordinate;
960
+ /** Coordinate at which the chosen image's resource anchor is painted. */
961
+ imageAnchor: PixelCoordinate;
962
+ ordinaryDisplay: PixelCoordinate;
963
+ attachment?: PixelCoordinate;
964
+ junction?: PixelCoordinate;
965
+ direction?: PixelCoordinate;
966
+ path: PixelCoordinate[];
967
+ kind: "ordinary" | "direct" | "indicator-only" | "with-extension";
968
+ status: "ok" | "unsupported-presentation";
969
+ scale: number;
970
+ /** Resolved connector styling in CSS pixels. */
971
+ connectorStyle: Required<PointSymbolOffsetStyle>;
972
+ }
973
+ /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
974
+ declare function resolvePointSymbolOffset(symbol: PointSymbol, render: PointSymbolRender, view: PointSymbolProjection): PointSymbolOffsetLayout | null;
975
+ /** Set body placement without changing the authoritative coordinate. */
976
+ declare function placePointSymbol(symbol: PointSymbol, render: PointSymbolRender, view: PointSymbolView, display: PixelCoordinate, mode?: PointSymbolPlacement["mode"]): PointSymbol;
977
+ declare function convertPointSymbolPlacement(symbol: PointSymbol, render: PointSymbolRender, view: PointSymbolView, mode: PointSymbolPlacement["mode"]): PointSymbol;
978
+ /** Explicit alignment, also used with hysteresis by pointer editing. */
979
+ declare function alignPointSymbolIndicator(symbol: PointSymbol, render: PointSymbolRender, view: PointSymbolView): PointSymbol;
980
+ declare function movePointSymbolLocation(symbol: PointSymbol, render: PointSymbolRender, view: PointSymbolView, position: Position): PointSymbol;
981
+ /** Canonical resource metadata shared by native adapters and external exporters. */
982
+ declare function pointSymbolResource(render: PointSymbolRender): IconResource;
983
+ /**
984
+ * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
985
+ * Scales positions, body scale, line width and dashes together, including the
986
+ * already-resolved offset and padding. Canonical resources, direction, rotation
987
+ * and opacity stay unchanged. Does not mutate the input or authored symbol.
988
+ */
989
+ declare function scalePointSymbolLayout(layout: PointSymbolOffsetLayout, factor: number): PointSymbolOffsetLayout;
990
+ //#endregion
898
991
  //#region src/tactical-draw/graphic-edit-session.d.ts
992
+ interface PointSymbolOffsetControls {
993
+ readonly layout: PointSymbolOffsetLayout | null;
994
+ readonly connectors: readonly PointSymbolConnector[];
995
+ setPlacement(mode: PointSymbolPlacement["mode"]): void;
996
+ setConnector(connector: PointSymbolConnector): void;
997
+ align(): void;
998
+ reset(): void;
999
+ }
899
1000
  interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
900
1001
  readonly source: SessionChangeSource;
901
1002
  readonly graphic: GraphicSnapshot<G>;
@@ -908,6 +1009,8 @@ interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
908
1009
  /** Common selection/editing contract shared by control measures and point symbols. */
909
1010
  interface GraphicEditSession<G extends Graphic = Graphic> {
910
1011
  readonly history: SessionHistory;
1012
+ /** Present on point-symbol sessions; all commands participate in session history. */
1013
+ readonly offset?: PointSymbolOffsetControls;
911
1014
  /** Immutable edit-start input. */
912
1015
  readonly graphic: G;
913
1016
  /** Live working graphic. Copy on read. */
@@ -1321,6 +1424,8 @@ interface InteractionHit {
1321
1424
  measureId?: string;
1322
1425
  }
1323
1426
  interface TacticalDrawOptions {
1427
+ /** Optional point-symbol body-drag shortcut; disabled when omitted or false. */
1428
+ offsetDragModifier?: OffsetDragModifier | false;
1324
1429
  layers?: TacticalDrawLayerOptions;
1325
1430
  /** Optional renderer capability for first-class point symbols (ADR-0029). */
1326
1431
  pointSymbols?: PointSymbolCapability;
@@ -1352,6 +1457,8 @@ interface TacticalDrawOptions {
1352
1457
  snapping?: SnappingOptions;
1353
1458
  }
1354
1459
  interface EditOptions {
1460
+ /** Override the point-symbol offset shortcut for this edit; false disables it. */
1461
+ offsetDragModifier?: OffsetDragModifier | false;
1355
1462
  signal?: AbortSignal;
1356
1463
  /**
1357
1464
  * Called synchronously once when the edit starts, before any pointer event
@@ -1503,6 +1610,7 @@ declare class TacticalDraw {
1503
1610
  private readonly symbolColors;
1504
1611
  private readonly defaultInteractionStyle;
1505
1612
  private readonly generateId;
1613
+ private offsetDragModifier;
1506
1614
  private readonly pointSymbols;
1507
1615
  private readonly images;
1508
1616
  private snappingOptions;
@@ -1553,6 +1661,8 @@ declare class TacticalDraw {
1553
1661
  private transformCounter;
1554
1662
  /** Snapshot of the host's latest authoritative Graphic membership and order. */
1555
1663
  private lastRenderedMeasures;
1664
+ /** Whether any last-rendered graphic reads the whole camera, not just scale. */
1665
+ private lastRenderedHasViewDependent;
1556
1666
  private snapGeometryFeatures;
1557
1667
  /**
1558
1668
  * Rendered graphics-layer groups, keyed on Graphic snapshot identity. Each
@@ -1590,6 +1700,8 @@ declare class TacticalDraw {
1590
1700
  */
1591
1701
  private hasHighlightOutlines;
1592
1702
  constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1703
+ /** Change the default shortcut for subsequent drags, including in an active edit. */
1704
+ setOffsetDragModifier(modifier: OffsetDragModifier | false): void;
1593
1705
  /** Replace snapping configuration without recreating the map adapter or façade. */
1594
1706
  setSnappingOptions(options?: SnappingOptions): void;
1595
1707
  /** Clear transient host affordances when snapping or its interaction ends. */
@@ -2034,14 +2146,18 @@ interface TransformBox {
2034
2146
  angle: number;
2035
2147
  }
2036
2148
  //#endregion
2149
+ //#region src/point-symbol-size.d.ts
2150
+ declare function effectivePointSymbolPixelSize(symbol: Pick<PointSymbol, "size">, resolution: number | undefined): number;
2151
+ //#endregion
2037
2152
  //#region src/tactical-draw/point-symbol-rendering.d.ts
2038
2153
  interface PointSymbolFootprint {
2039
2154
  box: TransformBox;
2040
2155
  corners: PixelCoordinate[];
2156
+ /** Connector path, separate from the body-only transform box. */
2157
+ connector: PixelCoordinate[];
2041
2158
  }
2042
- declare function effectivePointSymbolPixelSize(symbol: PointSymbol, resolution: number | undefined): number;
2043
2159
  /** Complete rotated logical footprint for selection and transform chrome. */
2044
- declare function pointSymbolFootprint(symbol: PointSymbol, render: PointSymbolRender, adapter: Pick<MapAdapter, "getBearing" | "getPixelFromCoordinate" | "getResolution">): PointSymbolFootprint | null;
2160
+ declare function pointSymbolFootprint(symbol: PointSymbol, render: PointSymbolRender, adapter: PointSymbolProjection): PointSymbolFootprint | null;
2045
2161
  //#endregion
2046
2162
  //#region src/tactical-draw/image-rendering.d.ts
2047
2163
  interface ImageFootprint {
@@ -2064,4 +2180,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
2064
2180
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
2065
2181
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
2066
2182
  //#endregion
2067
- export { type AnchoredGraphic, BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawImageDraft, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureGroup, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type ImageCapability, ImageCapabilityRequiredError, type ImageFootprint, type ImageGraphic, type ImageGraphicStyle, type ImageIntrinsicSize, type ImageOverlayResource, type ImageRender, type ImageResource, type ImageSize, type InteractionHit, type InteractionStyle, type LabelExtent, type LayerId, MINE_PATTERN_TILE_SIZE, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type MergedPickFeature, type NativeEventEntry, type PickCandidate, type PickCandidateHit, type PickCandidateHitKind, type PickCandidateInput, type PickEvaluationContext, type PickEvent, type PickHandler, type PickOptions, type PickPointerType, 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 ResolvedPickPresentation, type RotatedScreenBounds, type ScreenBounds, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TEXT_HALO_COLOR, 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, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextHaloWidth, resolveTextSizePixels, tryControlMeasureIdFromFeature, validateImageGraphic };
2183
+ export { type AnchoredGraphic, BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawImageDraft, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureGroup, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type ImageCapability, ImageCapabilityRequiredError, type ImageFootprint, type ImageGraphic, type ImageGraphicStyle, type ImageIntrinsicSize, type ImageOverlayResource, type ImageRender, type ImageResource, type ImageSize, type InteractionHit, type InteractionStyle, type LabelExtent, type LayerId, MINE_PATTERN_TILE_SIZE, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type MergedPickFeature, type NativeEventEntry, type OffsetDragModifier, type PickCandidate, type PickCandidateHit, type PickCandidateHitKind, type PickCandidateInput, type PickEvaluationContext, type PickEvent, type PickHandler, type PickOptions, type PickPointerType, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolConnector, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolOffset, type PointSymbolOffsetControls, type PointSymbolOffsetLayout, type PointSymbolOffsetStyle, type PointSymbolPlacement, type PointSymbolProjection, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointSymbolView, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type ResolvedPickPresentation, type RotatedScreenBounds, type ScreenBounds, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TEXT_HALO_COLOR, 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, alignPointSymbolIndicator, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, convertPointSymbolPlacement, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, movePointSymbolLocation, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, pickHitTolerance, pickPointerType, placePointSymbol, pointSymbolFootprint, pointSymbolResource, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolvePointSymbolOffset, resolveTextHaloWidth, resolveTextSizePixels, scalePointSymbolLayout, tryControlMeasureIdFromFeature, validateImageGraphic };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_LABEL_HEIGHT_CSS_PIXELS, DEFAULT_SYMBOL_COLOR, FILL_PATTERN_DEFINITIONS, NON_SOLID_FILL_PATTERNS, applyBoxTransformOptions, canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, dragControlMeasureOptionHandle, foldsBoxTransformOptions, freezeOrientationOptions, getControlMeasureMetadata, getControlMeasureOptionHandles, getDefaultOptions, normalizeTextAmplifiers, renderControlMeasure, resolveAmplifierPlacement, resolveSizePair, resolveStyleHints, roundToFixed, switchSizePairUnit } from "@orbat-mapper/control-measures";
1
+ import { DEFAULT_LABEL_HEIGHT_CSS_PIXELS, DEFAULT_SYMBOL_COLOR, FILL_PATTERN_DEFINITIONS, NON_SOLID_FILL_PATTERNS, applyBoxTransformOptions, canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, dragControlMeasureOptionHandle, foldsBoxTransformOptions, freezeOrientationOptions, getControlMeasureMetadata, getControlMeasureOptionHandles, getDefaultOptions, normalizeTextAmplifiers, renderControlMeasure, resolveAmplifierPlacement, resolveSizePair, resolveStyleHints, resolveSymbolColor, roundToFixed, switchSizePairUnit } from "@orbat-mapper/control-measures";
2
2
  function pickPointerType(originalEvent) {
3
3
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
4
4
  if (originalEvent.pointerType === "touch") return "touch";
@@ -2600,6 +2600,140 @@ function runEditInteraction(spec) {
2600
2600
  });
2601
2601
  }
2602
2602
  //#endregion
2603
+ //#region src/tactical-draw/transform-box.ts
2604
+ /**
2605
+ * The uniform scale factor and rotation delta a box implies relative to a
2606
+ * pre-drag box, given the gesture kind. A `body` translate reports neither
2607
+ * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2608
+ * (folding the delta into rotation/size options); the delta is pivot-
2609
+ * independent so either can apply it without knowing the box center.
2610
+ */
2611
+ function boxScaleRotation(kind, box, preDragBox) {
2612
+ return {
2613
+ scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2614
+ rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2615
+ };
2616
+ }
2617
+ function rotate(x, y, angle) {
2618
+ const cos = Math.cos(angle);
2619
+ const sin = Math.sin(angle);
2620
+ return [x * cos - y * sin, x * sin + y * cos];
2621
+ }
2622
+ /**
2623
+ * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
2624
+ * Rotates the points by `-angle` about their centroid, takes the axis-aligned
2625
+ * bounding box in that de-rotated frame, then re-expresses the result as an
2626
+ * oriented box at `angle` — so the box always has exactly the requested tilt,
2627
+ * sized to just contain the (de-rotated) points. Half-dimensions are floored
2628
+ * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
2629
+ * pickable box. Returns `null` for an empty list — there is nothing to fit.
2630
+ *
2631
+ * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
2632
+ * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
2633
+ * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
2634
+ * the rendered geometry. Everything downstream that reads a fitted box
2635
+ * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
2636
+ * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
2637
+ * placement, the rotate grip, and picking automatically — no other function
2638
+ * needs to know about it.
2639
+ */
2640
+ function fitTransformBox(pixels, angle, padding = 0) {
2641
+ if (pixels.length === 0) return null;
2642
+ let cx = 0;
2643
+ let cy = 0;
2644
+ for (const p of pixels) {
2645
+ cx += p[0];
2646
+ cy += p[1];
2647
+ }
2648
+ cx /= pixels.length;
2649
+ cy /= pixels.length;
2650
+ let minX = Infinity;
2651
+ let minY = Infinity;
2652
+ let maxX = -Infinity;
2653
+ let maxY = -Infinity;
2654
+ for (const p of pixels) {
2655
+ const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
2656
+ if (rx < minX) minX = rx;
2657
+ if (rx > maxX) maxX = rx;
2658
+ if (ry < minY) minY = ry;
2659
+ if (ry > maxY) maxY = ry;
2660
+ }
2661
+ const localCenterX = (minX + maxX) / 2;
2662
+ const localCenterY = (minY + maxY) / 2;
2663
+ const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
2664
+ const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
2665
+ const [ox, oy] = rotate(localCenterX, localCenterY, angle);
2666
+ return {
2667
+ center: [cx + ox, cy + oy],
2668
+ halfWidth,
2669
+ halfHeight,
2670
+ angle
2671
+ };
2672
+ }
2673
+ /**
2674
+ * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
2675
+ * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
2676
+ * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
2677
+ * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
2678
+ * anchor is always the opposite corner, `(index + 2) % 4`.
2679
+ */
2680
+ function boxCorners(box) {
2681
+ const { center, halfWidth, halfHeight, angle } = box;
2682
+ return [
2683
+ [-halfWidth, -halfHeight],
2684
+ [halfWidth, -halfHeight],
2685
+ [halfWidth, halfHeight],
2686
+ [-halfWidth, halfHeight]
2687
+ ].map(([x, y]) => {
2688
+ const [rx, ry] = rotate(x, y, angle);
2689
+ return [center[0] + rx, center[1] + ry];
2690
+ });
2691
+ }
2692
+ /**
2693
+ * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
2694
+ * above the top-edge midpoint, along the box's local "up" direction, so the
2695
+ * grip stays glued to the box (rotates with it) rather than floating at a
2696
+ * fixed screen offset. The label transform box passes its own tighter
2697
+ * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
2698
+ */
2699
+ function rotateGripPixel(box, offset = 28) {
2700
+ const { center, halfHeight, angle } = box;
2701
+ const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
2702
+ return [center[0] + rx, center[1] + ry];
2703
+ }
2704
+ /**
2705
+ * Point-in-oriented-rectangle test, used to start a body (translate) drag
2706
+ * when the pointer goes down inside the box but not on a corner/rotate grip.
2707
+ * De-rotates `pixel` into the box's local frame about `box.center` and
2708
+ * compares against the half-extents.
2709
+ */
2710
+ function pointInBox(box, pixel) {
2711
+ const { center, halfWidth, halfHeight, angle } = box;
2712
+ const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
2713
+ return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
2714
+ }
2715
+ /** Plain 2D distance between two pixel coordinates. */
2716
+ function pixelDistance$1(a, b) {
2717
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
2718
+ }
2719
+ /**
2720
+ * Pick what part of the box, if any, `pixel` hits — corners first, then the
2721
+ * rotate grip, then plain containment (body drag). Corners and the rotate
2722
+ * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
2723
+ * click just outside the box never starts a body drag. Returns `null` when
2724
+ * nothing matches.
2725
+ */
2726
+ function pickBoxHandle(box, pixel, tolerance) {
2727
+ const corners = boxCorners(box);
2728
+ for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
2729
+ kind: "scale",
2730
+ index: i
2731
+ };
2732
+ if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
2733
+ if (pointInBox(box, pixel)) return { kind: "body" };
2734
+ return null;
2735
+ }
2736
+ //#endregion
2603
2737
  //#region src/tactical-draw/handles.ts
2604
2738
  /**
2605
2739
  * Midpoint of the segment between two positions, in lonLat. The single source
@@ -2704,9 +2838,6 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
2704
2838
  };
2705
2839
  });
2706
2840
  }
2707
- function pixelDistance$2(a, b) {
2708
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2709
- }
2710
2841
  /**
2711
2842
  * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
2712
2843
  * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
@@ -2720,7 +2851,7 @@ function pixelDistance$2(a, b) {
2720
2851
  function pickHandle(handles, pixel, tolerance, toPixel) {
2721
2852
  for (const handle of handles) {
2722
2853
  const projected = toPixel(handle.position);
2723
- if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
2854
+ if (projected && pixelDistance$1(projected, pixel) <= tolerance) return handle;
2724
2855
  }
2725
2856
  return null;
2726
2857
  }
@@ -2738,139 +2869,6 @@ function constrainRotation(angle) {
2738
2869
  return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
2739
2870
  }
2740
2871
  //#endregion
2741
- //#region src/tactical-draw/transform-box.ts
2742
- /**
2743
- * The uniform scale factor and rotation delta a box implies relative to a
2744
- * pre-drag box, given the gesture kind. A `body` translate reports neither
2745
- * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2746
- * (folding the delta into rotation/size options); the delta is pivot-
2747
- * independent so either can apply it without knowing the box center.
2748
- */
2749
- function boxScaleRotation(kind, box, preDragBox) {
2750
- return {
2751
- scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2752
- rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2753
- };
2754
- }
2755
- function rotate(x, y, angle) {
2756
- const cos = Math.cos(angle);
2757
- const sin = Math.sin(angle);
2758
- return [x * cos - y * sin, x * sin + y * cos];
2759
- }
2760
- /**
2761
- * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
2762
- * Rotates the points by `-angle` about their centroid, takes the axis-aligned
2763
- * bounding box in that de-rotated frame, then re-expresses the result as an
2764
- * oriented box at `angle` — so the box always has exactly the requested tilt,
2765
- * sized to just contain the (de-rotated) points. Half-dimensions are floored
2766
- * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
2767
- * pickable box. Returns `null` for an empty list — there is nothing to fit.
2768
- *
2769
- * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
2770
- * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
2771
- * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
2772
- * the rendered geometry. Everything downstream that reads a fitted box
2773
- * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
2774
- * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
2775
- * placement, the rotate grip, and picking automatically — no other function
2776
- * needs to know about it.
2777
- */
2778
- function fitTransformBox(pixels, angle, padding = 0) {
2779
- if (pixels.length === 0) return null;
2780
- let cx = 0;
2781
- let cy = 0;
2782
- for (const p of pixels) {
2783
- cx += p[0];
2784
- cy += p[1];
2785
- }
2786
- cx /= pixels.length;
2787
- cy /= pixels.length;
2788
- let minX = Infinity;
2789
- let minY = Infinity;
2790
- let maxX = -Infinity;
2791
- let maxY = -Infinity;
2792
- for (const p of pixels) {
2793
- const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
2794
- if (rx < minX) minX = rx;
2795
- if (rx > maxX) maxX = rx;
2796
- if (ry < minY) minY = ry;
2797
- if (ry > maxY) maxY = ry;
2798
- }
2799
- const localCenterX = (minX + maxX) / 2;
2800
- const localCenterY = (minY + maxY) / 2;
2801
- const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
2802
- const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
2803
- const [ox, oy] = rotate(localCenterX, localCenterY, angle);
2804
- return {
2805
- center: [cx + ox, cy + oy],
2806
- halfWidth,
2807
- halfHeight,
2808
- angle
2809
- };
2810
- }
2811
- /**
2812
- * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
2813
- * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
2814
- * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
2815
- * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
2816
- * anchor is always the opposite corner, `(index + 2) % 4`.
2817
- */
2818
- function boxCorners(box) {
2819
- const { center, halfWidth, halfHeight, angle } = box;
2820
- return [
2821
- [-halfWidth, -halfHeight],
2822
- [halfWidth, -halfHeight],
2823
- [halfWidth, halfHeight],
2824
- [-halfWidth, halfHeight]
2825
- ].map(([x, y]) => {
2826
- const [rx, ry] = rotate(x, y, angle);
2827
- return [center[0] + rx, center[1] + ry];
2828
- });
2829
- }
2830
- /**
2831
- * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
2832
- * above the top-edge midpoint, along the box's local "up" direction, so the
2833
- * grip stays glued to the box (rotates with it) rather than floating at a
2834
- * fixed screen offset. The label transform box passes its own tighter
2835
- * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
2836
- */
2837
- function rotateGripPixel(box, offset = 28) {
2838
- const { center, halfHeight, angle } = box;
2839
- const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
2840
- return [center[0] + rx, center[1] + ry];
2841
- }
2842
- /**
2843
- * Point-in-oriented-rectangle test, used to start a body (translate) drag
2844
- * when the pointer goes down inside the box but not on a corner/rotate grip.
2845
- * De-rotates `pixel` into the box's local frame about `box.center` and
2846
- * compares against the half-extents.
2847
- */
2848
- function pointInBox(box, pixel) {
2849
- const { center, halfWidth, halfHeight, angle } = box;
2850
- const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
2851
- return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
2852
- }
2853
- function pixelDistance$1(a, b) {
2854
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2855
- }
2856
- /**
2857
- * Pick what part of the box, if any, `pixel` hits — corners first, then the
2858
- * rotate grip, then plain containment (body drag). Corners and the rotate
2859
- * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
2860
- * click just outside the box never starts a body drag. Returns `null` when
2861
- * nothing matches.
2862
- */
2863
- function pickBoxHandle(box, pixel, tolerance) {
2864
- const corners = boxCorners(box);
2865
- for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
2866
- kind: "scale",
2867
- index: i
2868
- };
2869
- if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
2870
- if (pointInBox(box, pixel)) return { kind: "body" };
2871
- return null;
2872
- }
2873
- //#endregion
2874
2872
  //#region src/tactical-draw/handle-drag.ts
2875
2873
  function clonePoints(points) {
2876
2874
  return points.map((p) => [...p]);
@@ -5190,7 +5188,7 @@ var ImageCapabilityRequiredError = class ImageCapabilityRequiredError extends Er
5190
5188
  }
5191
5189
  };
5192
5190
  //#endregion
5193
- //#region src/tactical-draw/point-symbol-rendering.ts
5191
+ //#region src/point-symbol-size.ts
5194
5192
  function effectivePointSymbolPixelSize(symbol, resolution) {
5195
5193
  if (symbol.size.unit === "pixels") return symbol.size.value;
5196
5194
  if (resolution === void 0 || resolution <= 0) return symbol.size.value;
@@ -5202,10 +5200,221 @@ function pointSymbolPixelScale(symbol, render, resolution) {
5202
5200
  if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
5203
5201
  return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
5204
5202
  }
5203
+ //#endregion
5204
+ //#region src/point-symbol-offset.ts
5205
+ const EPSILON = 1e-7;
5206
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
5207
+ /** Trim from the symbol attachment end, including across a junction when necessary. */
5208
+ function padAnchor(path, padding) {
5209
+ if (!Number.isFinite(padding) || padding <= 0) return path;
5210
+ const trimmed = [...path];
5211
+ while (trimmed.length > 1) {
5212
+ const start = trimmed.shift();
5213
+ const end = trimmed[0];
5214
+ const length = distance(start, end);
5215
+ if (length > padding) {
5216
+ const fraction = padding / length;
5217
+ trimmed.unshift([start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction]);
5218
+ return trimmed;
5219
+ }
5220
+ padding -= length;
5221
+ }
5222
+ return [];
5223
+ }
5224
+ /** Pure layout: projection input/output is in CSS pixels, including document contexts. */
5225
+ function resolvePointSymbolOffset(symbol, render, view) {
5226
+ const actual = view.getPixelFromCoordinate(symbol.position);
5227
+ if (!actual) return null;
5228
+ const scale = pointSymbolPixelScale(symbol, render, view.getResolution());
5229
+ const angle = symbol.rotation - view.getBearing();
5230
+ const metadata = render.offset;
5231
+ const registration = metadata?.ordinaryRegistration ?? render.octagonAnchor;
5232
+ const shift = rotate((registration.x - render.anchor.x) * scale, (registration.y - render.anchor.y) * scale, angle);
5233
+ const ordinaryDisplay = [actual[0] + shift[0], actual[1] + shift[1]];
5234
+ const layout = {
5235
+ render,
5236
+ resource: pointSymbolResource(render),
5237
+ bodyOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5238
+ rotation: angle,
5239
+ actual,
5240
+ display: ordinaryDisplay,
5241
+ imageAnchor: actual,
5242
+ ordinaryDisplay,
5243
+ path: [],
5244
+ kind: "ordinary",
5245
+ status: "ok",
5246
+ scale,
5247
+ connectorStyle: {
5248
+ strokeColor: symbol.offset?.style?.strokeColor ?? metadata?.strokeColor ?? "#000000",
5249
+ opacity: (symbol.offset?.style?.opacity ?? symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5250
+ strokeWidth: symbol.offset?.style?.strokeWidth ?? (metadata?.strokeWidth ?? 4) * scale,
5251
+ strokeDash: [...symbol.offset?.style?.strokeDash ?? []]
5252
+ }
5253
+ };
5254
+ if (!symbol.offset) return layout;
5255
+ const { placement, connector, anchorPadding = 0 } = symbol.offset;
5256
+ if (!metadata || connector !== "direct" && !metadata.indicator) return {
5257
+ ...layout,
5258
+ status: "unsupported-presentation"
5259
+ };
5260
+ const display = placement.mode === "ground" ? view.getPixelFromCoordinate(placement.position) : [ordinaryDisplay[0] + placement.offset[0], ordinaryDisplay[1] + placement.offset[1]];
5261
+ if (!display) return {
5262
+ ...layout,
5263
+ status: "unsupported-presentation"
5264
+ };
5265
+ if (distance(display, ordinaryDisplay) < EPSILON) return layout;
5266
+ const body = metadata.body;
5267
+ const offset = rotate((metadata.attachment.x - body.anchor.x) * scale, (metadata.attachment.y - body.anchor.y) * scale, angle);
5268
+ const attachment = [display[0] + offset[0], display[1] + offset[1]];
5269
+ const resolved = {
5270
+ ...layout,
5271
+ render: body,
5272
+ resource: pointSymbolResource(body),
5273
+ display,
5274
+ imageAnchor: display,
5275
+ attachment
5276
+ };
5277
+ if (connector === "direct") return {
5278
+ ...resolved,
5279
+ kind: "direct",
5280
+ path: padAnchor(distance(attachment, actual) < EPSILON ? [] : [attachment, actual], anchorPadding)
5281
+ };
5282
+ const indicator = metadata.indicator;
5283
+ const rawDirection = rotate(indicator.direction.x, indicator.direction.y, angle);
5284
+ const norm = Math.hypot(...rawDirection);
5285
+ const direction = [rawDirection[0] / norm, rawDirection[1] / norm];
5286
+ const dx = actual[0] - attachment[0], dy = actual[1] - attachment[1];
5287
+ const forward = dx * direction[0] + dy * direction[1];
5288
+ if (Math.abs(dx * direction[1] - dy * direction[0]) < EPSILON && forward >= (indicator.minLength ?? 0) * scale) return {
5289
+ ...resolved,
5290
+ direction,
5291
+ kind: "indicator-only",
5292
+ path: padAnchor([attachment, actual], anchorPadding)
5293
+ };
5294
+ const length = symbol.offset.indicatorLength !== void 0 ? symbol.offset.indicatorLength * render.octagonSize : indicator.length;
5295
+ const junction = [attachment[0] + direction[0] * length * scale, attachment[1] + direction[1] * length * scale];
5296
+ return {
5297
+ ...resolved,
5298
+ direction,
5299
+ junction,
5300
+ kind: "with-extension",
5301
+ status: "ok",
5302
+ path: padAnchor(distance(junction, actual) < EPSILON ? [attachment, junction] : [
5303
+ attachment,
5304
+ junction,
5305
+ actual
5306
+ ], anchorPadding)
5307
+ };
5308
+ }
5309
+ /**
5310
+ * Authored indicator length that puts the junction at `point`: the forward
5311
+ * projection onto the attachment ray, expressed as a multiple of the displayed
5312
+ * octagon size and clamped to a non-negative length.
5313
+ */
5314
+ function indicatorLengthAt(point, attachment, direction, scale, octagonSize) {
5315
+ const forward = (point[0] - attachment[0]) * direction[0] + (point[1] - attachment[1]) * direction[1];
5316
+ return Math.max(0, forward / (scale * octagonSize));
5317
+ }
5318
+ /** Set body placement without changing the authoritative coordinate. */
5319
+ function placePointSymbol(symbol, render, view, display, mode = symbol.offset?.placement.mode ?? "screen-relative") {
5320
+ if (!render.offset) throw new TypeError("This renderer does not support offset placement");
5321
+ const ordinary = resolvePointSymbolOffset({
5322
+ ...symbol,
5323
+ offset: void 0
5324
+ }, render, view);
5325
+ if (!ordinary) throw new RangeError("Point symbol cannot be projected in this view");
5326
+ const next = clonePointSymbol(symbol);
5327
+ const coordinate = mode === "ground" ? view.getCoordinateFromPixel?.(display) : void 0;
5328
+ if (mode === "ground" && !coordinate) throw new RangeError("Ground placement requires inverse projection");
5329
+ next.offset = {
5330
+ ...next.offset,
5331
+ connector: next.offset?.connector ?? (render.offset.indicator ? "auto" : "direct"),
5332
+ placement: mode === "ground" ? {
5333
+ mode,
5334
+ position: [...coordinate]
5335
+ } : {
5336
+ mode,
5337
+ offset: [display[0] - ordinary.display[0], display[1] - ordinary.display[1]]
5338
+ }
5339
+ };
5340
+ return next;
5341
+ }
5342
+ function convertPointSymbolPlacement(symbol, render, view, mode) {
5343
+ const layout = resolvePointSymbolOffset(symbol, render, view);
5344
+ if (!layout || layout.status === "unsupported-presentation") throw new TypeError("Offset presentation is unavailable");
5345
+ return placePointSymbol(symbol, render, view, layout.display, mode);
5346
+ }
5347
+ /** Explicit alignment, also used with hysteresis by pointer editing. */
5348
+ function alignPointSymbolIndicator(symbol, render, view) {
5349
+ const layout = resolvePointSymbolOffset(symbol, render, view);
5350
+ if (!layout?.attachment || !layout.direction || !render.offset?.indicator) return symbol;
5351
+ const d = layout.direction;
5352
+ const delta = [layout.actual[0] - layout.attachment[0], layout.actual[1] - layout.attachment[1]];
5353
+ const forward = Math.max((render.offset.indicator.minLength ?? 0) * layout.scale, delta[0] * d[0] + delta[1] * d[1]);
5354
+ return placePointSymbol(symbol, render, view, [layout.display[0] + delta[0] - forward * d[0], layout.display[1] + delta[1] - forward * d[1]]);
5355
+ }
5356
+ function movePointSymbolLocation(symbol, render, view, position) {
5357
+ const before = resolvePointSymbolOffset(symbol, render, view);
5358
+ let next = {
5359
+ ...clonePointSymbol(symbol),
5360
+ position: [...position]
5361
+ };
5362
+ if (!symbol.offset || !before || before.status === "unsupported-presentation") return next;
5363
+ next = placePointSymbol(next, render, view, before.display);
5364
+ const after = resolvePointSymbolOffset(next, render, view);
5365
+ if (before.junction && after?.attachment && after.direction && symbol.offset.indicatorLength !== void 0) next.offset.indicatorLength = indicatorLengthAt(before.junction, after.attachment, after.direction, after.scale, render.octagonSize);
5366
+ return next;
5367
+ }
5368
+ /** Canonical resource metadata shared by native adapters and external exporters. */
5369
+ function pointSymbolResource(render) {
5370
+ return {
5371
+ id: render.resourceId,
5372
+ svg: render.svg,
5373
+ width: render.width,
5374
+ height: render.height,
5375
+ anchor: [render.anchor.x, render.anchor.y],
5376
+ coreRect: {
5377
+ x: render.octagonAnchor.x - render.octagonSize / 2,
5378
+ y: render.octagonAnchor.y - render.octagonSize / 2,
5379
+ width: render.octagonSize,
5380
+ height: render.octagonSize
5381
+ }
5382
+ };
5383
+ }
5384
+ /**
5385
+ * Convert a resolved CSS-pixel layout to output units (e.g. 25.4 / 96 for mm).
5386
+ * Scales positions, body scale, line width and dashes together, including the
5387
+ * already-resolved offset and padding. Canonical resources, direction, rotation
5388
+ * and opacity stay unchanged. Does not mutate the input or authored symbol.
5389
+ */
5390
+ function scalePointSymbolLayout(layout, factor) {
5391
+ if (!Number.isFinite(factor) || factor <= 0) throw new RangeError("Layout scale factor must be positive and finite");
5392
+ const point = ([x, y]) => [x * factor, y * factor];
5393
+ return {
5394
+ ...layout,
5395
+ actual: point(layout.actual),
5396
+ display: point(layout.display),
5397
+ imageAnchor: point(layout.imageAnchor),
5398
+ ordinaryDisplay: point(layout.ordinaryDisplay),
5399
+ attachment: layout.attachment && point(layout.attachment),
5400
+ junction: layout.junction && point(layout.junction),
5401
+ path: layout.path.map(point),
5402
+ scale: layout.scale * factor,
5403
+ connectorStyle: {
5404
+ ...layout.connectorStyle,
5405
+ strokeWidth: layout.connectorStyle.strokeWidth * factor,
5406
+ strokeDash: layout.connectorStyle.strokeDash.map((length) => length * factor)
5407
+ }
5408
+ };
5409
+ }
5410
+ //#endregion
5411
+ //#region src/tactical-draw/point-symbol-rendering.ts
5205
5412
  /** Complete rotated logical footprint for selection and transform chrome. */
5206
5413
  function pointSymbolFootprint(symbol, render, adapter) {
5207
- const anchor = adapter.getPixelFromCoordinate(symbol.position);
5208
- if (!anchor) return null;
5414
+ const layout = resolvePointSymbolOffset(symbol, render, adapter);
5415
+ if (!layout) return null;
5416
+ const anchor = layout.imageAnchor;
5417
+ render = layout.render;
5209
5418
  const scale = pointSymbolPixelScale(symbol, render, adapter.getResolution());
5210
5419
  const angle = symbol.rotation - adapter.getBearing();
5211
5420
  const centerOffsetX = (render.width / 2 - render.anchor.x) * scale;
@@ -5220,27 +5429,64 @@ function pointSymbolFootprint(symbol, render, adapter) {
5220
5429
  };
5221
5430
  return {
5222
5431
  box,
5223
- corners: boxCorners(box)
5432
+ corners: boxCorners(box),
5433
+ connector: layout.path
5224
5434
  };
5225
5435
  }
5436
+ function resolveAdapterLayout(symbol, render, adapter) {
5437
+ let layout = resolvePointSymbolOffset(symbol, render, adapter);
5438
+ if (layout && layout.kind !== "ordinary" && (!adapter.getCoordinateFromPixel?.(layout.imageAnchor) || layout.path.some((p) => !adapter.getCoordinateFromPixel?.(p)))) {
5439
+ const ordinary = resolvePointSymbolOffset({
5440
+ ...symbol,
5441
+ offset: void 0
5442
+ }, render, adapter);
5443
+ layout = ordinary ? {
5444
+ ...ordinary,
5445
+ status: "unsupported-presentation"
5446
+ } : null;
5447
+ }
5448
+ return layout;
5449
+ }
5226
5450
  function renderPointSymbolForAdapter(adapter, capability, symbol) {
5227
- const render = capability.render(symbol);
5228
- return {
5229
- render,
5230
- resource: {
5231
- id: render.resourceId,
5232
- svg: render.svg,
5233
- width: render.width,
5234
- height: render.height,
5235
- anchor: [render.anchor.x, render.anchor.y],
5236
- coreRect: {
5237
- x: render.octagonAnchor.x - render.octagonSize / 2,
5238
- y: render.octagonAnchor.y - render.octagonSize / 2,
5239
- width: render.octagonSize,
5240
- height: render.octagonSize
5451
+ const intrinsic = capability.render(symbol);
5452
+ const layout = resolveAdapterLayout(symbol, intrinsic, adapter);
5453
+ const render = layout?.render ?? intrinsic;
5454
+ const feature = pointSymbolFeature(symbol, render, adapter.getResolution());
5455
+ const coordinate = layout && adapter.getCoordinateFromPixel?.(layout.imageAnchor);
5456
+ if (coordinate) feature.geometry = {
5457
+ type: "Point",
5458
+ coordinates: coordinate
5459
+ };
5460
+ feature.properties.offsetStatus = layout?.status ?? "unsupported-presentation";
5461
+ feature.properties.connectorKind = layout?.kind ?? "ordinary";
5462
+ if (symbol.offset) {
5463
+ feature.properties.iconScreenRotation = symbol.rotation - adapter.getBearing();
5464
+ delete feature.properties.iconSizeMeters;
5465
+ delete feature.properties.iconOctagonSize;
5466
+ }
5467
+ const features = [];
5468
+ if (layout && layout.path.length > 1 && adapter.getCoordinateFromPixel) {
5469
+ const coordinates = layout.path.map((p) => adapter.getCoordinateFromPixel(p));
5470
+ if (coordinates.every((p) => p !== null)) features.push({
5471
+ type: "Feature",
5472
+ id: `${symbol.id}:connector`,
5473
+ geometry: {
5474
+ type: "LineString",
5475
+ coordinates
5476
+ },
5477
+ properties: {
5478
+ graphicId: symbol.id,
5479
+ graphicKind: "point-symbol",
5480
+ style: resolveSymbolColor(layout.connectorStyle, false)
5241
5481
  }
5242
- },
5243
- feature: pointSymbolFeature(symbol, render, adapter.getResolution())
5482
+ });
5483
+ }
5484
+ features.push(feature);
5485
+ return {
5486
+ render: intrinsic,
5487
+ layout,
5488
+ features,
5489
+ resource: layout?.resource ?? pointSymbolResource(render)
5244
5490
  };
5245
5491
  }
5246
5492
  function pointSymbolFeature(symbol, render, resolution) {
@@ -5264,7 +5510,7 @@ function pointSymbolFeature(symbol, render, resolution) {
5264
5510
  iconId: render.resourceId,
5265
5511
  iconScale: pointSymbolPixelScale(symbol, render, resolution),
5266
5512
  iconRotation: symbol.rotation,
5267
- iconOpacity: symbol.style?.opacity ?? 1,
5513
+ iconOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5268
5514
  ...groundSizeProperties
5269
5515
  }
5270
5516
  };
@@ -5494,6 +5740,7 @@ function runGroupTransform(opts) {
5494
5740
  function createMemberState(graphic) {
5495
5741
  if (isAnchoredGraphic(graphic)) {
5496
5742
  const editStartPoints = [[...graphic.position]];
5743
+ if (isPointSymbol(graphic) && graphic.offset?.placement.mode === "ground") editStartPoints.push([...graphic.offset.placement.position]);
5497
5744
  return {
5498
5745
  graphic,
5499
5746
  editStartPoints,
@@ -5546,6 +5793,14 @@ function runGroupTransform(opts) {
5546
5793
  symbol.position = [...member.workingPoints[0]];
5547
5794
  symbol.size = resizePointSymbolSize(symbol.size, member.workingSize);
5548
5795
  symbol.rotation = member.workingRotation;
5796
+ if (symbol.offset?.placement.mode === "ground") symbol.offset.placement.position = [...member.workingPoints[1]];
5797
+ else if (symbol.offset?.placement.mode === "screen-relative") {
5798
+ const [x, y] = symbol.offset.placement.offset;
5799
+ const angle = member.workingRotation - member.editStartRotation;
5800
+ const scale = member.workingSize / member.editStartSize;
5801
+ const [rx, ry] = rotate(x, y, angle);
5802
+ symbol.offset.placement.offset = [rx * scale, ry * scale];
5803
+ }
5549
5804
  return symbol;
5550
5805
  }
5551
5806
  function buildWorkingImage(member) {
@@ -5736,7 +5991,7 @@ function runGroupTransform(opts) {
5736
5991
  const symbol = buildWorkingPointSymbol(member);
5737
5992
  const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
5738
5993
  iconResources = [plan.resource];
5739
- features = [plan.feature];
5994
+ features = plan.features;
5740
5995
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
5741
5996
  if (footprint) boxPixels = footprint.corners;
5742
5997
  } else {
@@ -6303,7 +6558,7 @@ function runPointSymbolDraw(options) {
6303
6558
  const plan = renderPointSymbolForAdapter(adapter, capability, build(PREVIEW_ID$1, latestPosition));
6304
6559
  ctx.writeLayer(previewLayer, [{
6305
6560
  id: PREVIEW_ID$1,
6306
- features: [plan.feature],
6561
+ features: plan.features,
6307
6562
  iconResources: [plan.resource]
6308
6563
  }]);
6309
6564
  };
@@ -6343,9 +6598,19 @@ function runPointSymbolDraw(options) {
6343
6598
  }
6344
6599
  //#endregion
6345
6600
  //#region src/tactical-draw/point-symbol-edit-controller.ts
6601
+ /**
6602
+ * Rotating or resizing a displaced symbol keeps its body where the user sees
6603
+ * it (ADR-0054 rules 7/8). Both the pointer path and `updateGraphic` route
6604
+ * through here so the invariant is enforced in one place.
6605
+ */
6606
+ function preserveDisplayPlacement(next, render, view, layout) {
6607
+ if (!next.offset || !render.offset) return next;
6608
+ if (!layout || layout.status === "unsupported-presentation") return next;
6609
+ return placePointSymbol(next, render, view, layout.display);
6610
+ }
6346
6611
  function normalisePointModes(input) {
6347
6612
  const modes = input ?? ["transform"];
6348
- for (const mode of modes) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
6613
+ for (const mode of modes) if (mode !== "transform" && mode !== "delete" && mode !== "offset") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
6349
6614
  return modes.includes("delete") ? ["delete"] : [...new Set(modes)];
6350
6615
  }
6351
6616
  function symbolBox(adapter, symbol, rendered, padding = 0) {
@@ -6359,11 +6624,12 @@ function symbolBox(adapter, symbol, rendered, padding = 0) {
6359
6624
  }
6360
6625
  function runPointSymbolEdit(opts) {
6361
6626
  const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, publishWorkingGraphic, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
6362
- let modes = normalisePointModes(opts.modes);
6627
+ let modes = normalisePointModes(opts.modes ?? (symbol.offset ? ["transform", "offset"] : ["transform"]));
6363
6628
  let usesGraphicStack = wasOnGraphicsLayer;
6364
6629
  let working = clonePointSymbol(symbol);
6365
6630
  let dirty = false;
6366
6631
  let box = null;
6632
+ let currentLayout = null;
6367
6633
  let drag = null;
6368
6634
  let ctx;
6369
6635
  let driver;
@@ -6385,21 +6651,48 @@ function runPointSymbolEdit(opts) {
6385
6651
  const render = () => {
6386
6652
  if (ctx.settled) return;
6387
6653
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
6654
+ currentLayout = plan.layout;
6388
6655
  const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
6389
6656
  const handles = [];
6390
6657
  if (modes.includes("transform") && nextBox) {
6391
6658
  handles.push(boxOutlineFeature(nextBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
6392
6659
  handles.push(...toFeatures(boxToHandles(nextBox, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
6393
6660
  }
6661
+ if (modes.includes("offset") && working.offset && plan.layout?.status !== "unsupported-presentation") {
6662
+ [plan.layout?.actual, plan.layout?.junction].forEach((pixel, index) => {
6663
+ if (!pixel) return;
6664
+ handles.push(...toFeatures([{
6665
+ kind: index === 0 ? "vertex" : "option",
6666
+ index,
6667
+ position: driver.fromPixel(pixel)
6668
+ }], `${handleIdPrefix}:offset`, interactionStyle));
6669
+ });
6670
+ if (drag?.snapped && plan.layout?.attachment && plan.layout.direction) {
6671
+ const a = plan.layout.attachment, d = plan.layout.direction;
6672
+ handles.push({
6673
+ type: "Feature",
6674
+ id: `${handleIdPrefix}:alignment`,
6675
+ geometry: {
6676
+ type: "LineString",
6677
+ coordinates: [driver.fromPixel([a[0] - 200 * d[0], a[1] - 200 * d[1]]), driver.fromPixel([a[0] + 200 * d[0], a[1] + 200 * d[1]])]
6678
+ },
6679
+ properties: { style: {
6680
+ strokeColor: "#0284c7",
6681
+ strokeWidth: 1,
6682
+ strokeDash: [4, 4]
6683
+ } }
6684
+ });
6685
+ }
6686
+ }
6394
6687
  publishWorkingGraphic({
6395
6688
  id: working.id,
6396
- features: [plan.feature],
6689
+ features: plan.features,
6397
6690
  iconResources: [plan.resource]
6398
6691
  });
6399
6692
  if (usesGraphicStack) ctx.writeLayer(previewLayer, []);
6400
6693
  else ctx.writeLayer(previewLayer, [{
6401
6694
  id: `__td_point_symbol_edit_preview__:${working.id}`,
6402
- features: [plan.feature],
6695
+ features: plan.features,
6403
6696
  iconResources: [plan.resource]
6404
6697
  }]);
6405
6698
  box = nextBox;
@@ -6449,6 +6742,37 @@ function runPointSymbolEdit(opts) {
6449
6742
  };
6450
6743
  const session = {
6451
6744
  history,
6745
+ offset: {
6746
+ get layout() {
6747
+ return currentLayout ? structuredClone(currentLayout) : null;
6748
+ },
6749
+ get connectors() {
6750
+ const metadata = capability.render(working).offset;
6751
+ return !metadata ? [] : metadata.indicator ? ["auto", "direct"] : ["direct"];
6752
+ },
6753
+ setPlacement(mode) {
6754
+ if (ctx.settled) return;
6755
+ apply(convertPointSymbolPlacement(working, capability.render(working), adapter, mode), "programmatic", "Placement mode");
6756
+ },
6757
+ setConnector(connector) {
6758
+ if (ctx.settled) return;
6759
+ const intrinsic = capability.render(working);
6760
+ if (!intrinsic.offset || connector !== "direct" && !intrinsic.offset.indicator) throw new TypeError("Unsupported connector");
6761
+ const next = working.offset ? clonePointSymbol(working) : convertPointSymbolPlacement(working, intrinsic, adapter, "screen-relative");
6762
+ next.offset.connector = connector;
6763
+ apply(next, "programmatic", "Connector preference");
6764
+ },
6765
+ align() {
6766
+ if (!ctx.settled) apply(alignPointSymbolIndicator(working, capability.render(working), adapter), "programmatic", "Align indicator");
6767
+ },
6768
+ reset() {
6769
+ if (!ctx.settled) {
6770
+ const next = clonePointSymbol(working);
6771
+ delete next.offset;
6772
+ apply(next, "programmatic", "Reset offset");
6773
+ }
6774
+ }
6775
+ },
6452
6776
  graphic: clonePointSymbol(symbol),
6453
6777
  get workingGraphic() {
6454
6778
  return clonePointSymbol(working);
@@ -6474,8 +6798,8 @@ function runPointSymbolEdit(opts) {
6474
6798
  if (ctx.settled) return;
6475
6799
  const next = updater(clonePointSymbol(working));
6476
6800
  if (next.kind !== "point-symbol" || next.id !== symbol.id) throw new TypeError("updateGraphic must preserve point-symbol kind and id");
6477
- capability.render(next);
6478
- apply(next, "programmatic", "Update graphic");
6801
+ const nextRender = capability.render(next);
6802
+ apply(working.offset !== void 0 && next.offset !== void 0 && deepEqual(working.offset, next.offset) && deepEqual(working.position, next.position) && (working.rotation !== next.rotation || !deepEqual(working.size, next.size)) ? preserveDisplayPlacement(next, nextRender, adapter, currentLayout) : next, "programmatic", "Update graphic");
6479
6803
  },
6480
6804
  onChange(listener) {
6481
6805
  if (ctx.settled) return () => {};
@@ -6491,41 +6815,87 @@ function runPointSymbolEdit(opts) {
6491
6815
  };
6492
6816
  const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
6493
6817
  const onDown = (pixel, info) => {
6494
- if (ctx.settled || !box || !modes.includes("transform")) return;
6495
- const hit = pickBoxHandle(box, pixel, info?.hitTolerance ?? 12);
6496
- if (!hit) return;
6497
- const anchor = driver.toPixel(working.position);
6498
- if (!anchor) return;
6818
+ if (ctx.settled || !box || !currentLayout || modes.includes("delete")) return;
6819
+ let offsetMode = modes.includes("offset");
6820
+ if (!offsetMode && !modes.includes("transform")) return;
6821
+ const tolerance = info?.hitTolerance ?? 12;
6822
+ const near = (point) => point && pixelDistance$1(point, pixel) <= tolerance;
6823
+ let kind;
6824
+ if (offsetMode && working.offset && currentLayout.status !== "unsupported-presentation") {
6825
+ if (near(currentLayout.actual)) kind = "location";
6826
+ else if (near(currentLayout.junction)) kind = "junction";
6827
+ }
6828
+ if (!kind) {
6829
+ const hit = pickBoxHandle(box, pixel, tolerance);
6830
+ if (hit?.kind === "body" || hit && modes.includes("transform")) kind = hit?.kind;
6831
+ }
6832
+ const render = capability.render(working);
6833
+ const modifier = opts.offsetDragModifier?.();
6834
+ if (kind === "body" && modifier && info?.[`${modifier}Key`] && currentLayout.status !== "unsupported-presentation" && render.offset) offsetMode = true;
6835
+ if (!kind || offsetMode && !render.offset) return;
6836
+ const anchor = kind === "body" || kind === "location" || !working.offset ? currentLayout.actual : currentLayout.display;
6499
6837
  drag = {
6500
- kind: hit.kind,
6838
+ kind,
6839
+ offsetMode,
6840
+ render,
6841
+ layout: structuredClone(currentLayout),
6842
+ snapped: false,
6501
6843
  down: [...pixel],
6502
6844
  start: clonePointSymbol(working),
6503
6845
  anchor,
6504
6846
  startPointerAngle: pointerAngle(pixel, anchor),
6505
- startDistance: Math.max(1, Math.hypot(pixel[0] - anchor[0], pixel[1] - anchor[1]))
6847
+ startDistance: Math.max(1, pixelDistance$1(pixel, anchor))
6506
6848
  };
6507
6849
  driver.setDragPanEnabled(false);
6508
6850
  };
6509
6851
  const transformed = (pixel, info) => {
6510
6852
  if (!drag) return null;
6511
- const next = clonePointSymbol(drag.start);
6512
- if (drag.kind === "body") {
6853
+ let next = clonePointSymbol(drag.start);
6854
+ const intrinsic = drag.render;
6855
+ if (drag.kind === "body" && drag.offsetMode) {
6856
+ const display = [drag.layout.display[0] + pixel[0] - drag.down[0], drag.layout.display[1] + pixel[1] - drag.down[1]];
6857
+ next = placePointSymbol(next, intrinsic, adapter, display);
6858
+ const aligned = alignPointSymbolIndicator(next, intrinsic, adapter);
6859
+ const alignedLayout = resolvePointSymbolOffset(aligned, intrinsic, adapter);
6860
+ const gap = alignedLayout ? pixelDistance$1(alignedLayout.display, display) : Infinity;
6861
+ if (next.offset.connector === "auto" && gap <= (drag.snapped ? 10 : 6)) {
6862
+ next = aligned;
6863
+ drag.snapped = true;
6864
+ } else drag.snapped = false;
6865
+ return next;
6866
+ }
6867
+ if (drag.kind === "junction") {
6868
+ const layout = drag.layout;
6869
+ if (!layout.attachment || !layout.direction || !layout.junction || !next.offset) return null;
6870
+ const desired = [layout.junction[0] + pixel[0] - drag.down[0], layout.junction[1] + pixel[1] - drag.down[1]];
6871
+ next.offset.connector = "auto";
6872
+ next.offset.indicatorLength = indicatorLengthAt(desired, layout.attachment, layout.direction, layout.scale, intrinsic.octagonSize);
6873
+ return next;
6874
+ }
6875
+ if (drag.kind === "body" || drag.kind === "location") {
6513
6876
  const unsnappedPosition = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
6514
6877
  const anchorPixel = driver.toPixel(unsnappedPosition) ?? pixel;
6515
- next.position = (snap?.({
6878
+ const position = (snap?.({
6516
6879
  coordinate: unsnappedPosition,
6517
6880
  pixel: anchorPixel,
6518
6881
  interaction: "translate",
6519
6882
  graphicId: symbol.id,
6520
6883
  originalEvent: info?.native
6521
6884
  }))?.coordinate ?? unsnappedPosition;
6885
+ if (drag.kind === "location") return movePointSymbolLocation(next, intrinsic, adapter, position);
6886
+ next.position = position;
6887
+ if (next.offset?.placement.mode === "ground") {
6888
+ const actual = driver.toPixel(position);
6889
+ next = placePointSymbol(next, intrinsic, adapter, [drag.layout.display[0] + actual[0] - drag.layout.actual[0], drag.layout.display[1] + actual[1] - drag.layout.actual[1]]);
6890
+ }
6522
6891
  } else if (drag.kind === "rotate") {
6523
6892
  const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
6524
6893
  next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
6525
6894
  } else {
6526
- const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
6895
+ const distance = pixelDistance$1(pixel, drag.anchor);
6527
6896
  next.size = resizePointSymbolSize(drag.start.size, Math.max(.1, drag.start.size.value * (distance / drag.startDistance)));
6528
6897
  }
6898
+ if (drag.kind === "rotate" || drag.kind === "scale") next = preserveDisplayPlacement(next, capability.render(next), adapter, drag.layout);
6529
6899
  return next;
6530
6900
  };
6531
6901
  const onMove = (pixel, info) => {
@@ -6997,6 +7367,7 @@ var TacticalDraw = class {
6997
7367
  symbolColors;
6998
7368
  defaultInteractionStyle;
6999
7369
  generateId;
7370
+ offsetDragModifier;
7000
7371
  pointSymbols;
7001
7372
  images;
7002
7373
  snappingOptions;
@@ -7047,6 +7418,8 @@ var TacticalDraw = class {
7047
7418
  transformCounter = 0;
7048
7419
  /** Snapshot of the host's latest authoritative Graphic membership and order. */
7049
7420
  lastRenderedMeasures = [];
7421
+ /** Whether any last-rendered graphic reads the whole camera, not just scale. */
7422
+ lastRenderedHasViewDependent = false;
7050
7423
  snapGeometryFeatures = [];
7051
7424
  /**
7052
7425
  * Rendered graphics-layer groups, keyed on Graphic snapshot identity. Each
@@ -7085,6 +7458,7 @@ var TacticalDraw = class {
7085
7458
  hasHighlightOutlines = false;
7086
7459
  constructor(adapter, options) {
7087
7460
  this.adapter = adapter;
7461
+ this.offsetDragModifier = options?.offsetDragModifier ?? false;
7088
7462
  this.symbolColors = options?.symbolColors ? { ...options.symbolColors } : void 0;
7089
7463
  this.lastViewResolution = adapter.getResolution();
7090
7464
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
@@ -7111,6 +7485,11 @@ var TacticalDraw = class {
7111
7485
  this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
7112
7486
  this.adapter.onViewChange(this.handleViewChange);
7113
7487
  }
7488
+ /** Change the default shortcut for subsequent drags, including in an active edit. */
7489
+ setOffsetDragModifier(modifier) {
7490
+ this.assertAlive();
7491
+ this.offsetDragModifier = modifier;
7492
+ }
7114
7493
  /** Replace snapping configuration without recreating the map adapter or façade. */
7115
7494
  setSnappingOptions(options) {
7116
7495
  this.assertAlive();
@@ -7141,15 +7520,15 @@ var TacticalDraw = class {
7141
7520
  renderGraphicGroup(graphic) {
7142
7521
  const cached = this.graphicGroupCache.get(graphic);
7143
7522
  if (cached) {
7144
- if (!cached.resolutionDependent) return cached.group;
7145
- if (Object.is(cached.resolution, this.adapter.getResolution())) return cached.group;
7523
+ if (cached.dependence === "none") return cached.group;
7524
+ if (cached.dependence === "resolution" && Object.is(cached.resolution, this.adapter.getResolution())) return cached.group;
7146
7525
  }
7147
- const resolutionDependent = graphicRendersFromViewState(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7148
- const resolution = resolutionDependent ? this.adapter.getResolution() : void 0;
7526
+ const dependence = graphicViewDependence(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7527
+ const resolution = dependence === "none" ? void 0 : this.adapter.getResolution();
7149
7528
  const group = this.buildGraphicGroup(graphic);
7150
7529
  this.graphicGroupCache.set(graphic, {
7151
7530
  group,
7152
- resolutionDependent,
7531
+ dependence,
7153
7532
  resolution
7154
7533
  });
7155
7534
  return group;
@@ -7166,7 +7545,7 @@ var TacticalDraw = class {
7166
7545
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
7167
7546
  return {
7168
7547
  id: graphic.id,
7169
- features: [plan.feature],
7548
+ features: plan.features,
7170
7549
  iconResources: [plan.resource],
7171
7550
  revision
7172
7551
  };
@@ -7247,11 +7626,11 @@ var TacticalDraw = class {
7247
7626
  this.lastViewResolution = resolution;
7248
7627
  if (!this.hasRendered) return;
7249
7628
  this.renderHighlights();
7250
- if (!resolutionChanged) return;
7629
+ const nativePointSymbolScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
7630
+ if (!resolutionChanged && !this.lastRenderedHasViewDependent) return;
7251
7631
  const excluded = this.graphicsStackExcludedIds();
7252
7632
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
7253
- const nativePointSymbolScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
7254
- if (!visible.some((g) => graphicRendersFromViewState(g, nativePointSymbolScaling))) return;
7633
+ if (!visible.some((g) => graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7255
7634
  this.reconcileGraphicsStack(visible);
7256
7635
  };
7257
7636
  /**
@@ -7335,6 +7714,7 @@ var TacticalDraw = class {
7335
7714
  }
7336
7715
  }
7337
7716
  this.lastRenderedMeasures = measures.slice();
7717
+ this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
7338
7718
  this.graphicStack.acknowledgeHostRender(measures);
7339
7719
  if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && graphic.kind === activeEdit.graphicKind)) activeEdit.syncHostMembership(true);
7340
7720
  (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
@@ -7686,6 +8066,7 @@ var TacticalDraw = class {
7686
8066
  adapter: this.adapter,
7687
8067
  capability: this.pointSymbols,
7688
8068
  symbol: measure,
8069
+ offsetDragModifier: () => options?.offsetDragModifier ?? this.offsetDragModifier,
7689
8070
  graphicsLayer: this.graphicsLayer,
7690
8071
  previewLayer: this.previewLayer,
7691
8072
  handlesLayer: this.handlesLayer,
@@ -8157,13 +8538,21 @@ var TacticalDraw = class {
8157
8538
  if (this.destroyed) throw new TacticalDrawDestroyedError();
8158
8539
  }
8159
8540
  };
8160
- function graphicRendersFromViewState(graphic, nativePointSymbolScaling) {
8161
- if (isPointSymbol(graphic)) return graphic.size.unit === "meters" && !nativePointSymbolScaling;
8162
- if (isImageGraphic(graphic)) return false;
8163
- return measureRendersFromViewState(graphic);
8541
+ /**
8542
+ * Classify what live view state a graphic's render reads. Offset point symbols
8543
+ * resolve their body and connector in pixel space, so they depend on the whole
8544
+ * camera rather than on scale alone.
8545
+ */
8546
+ function graphicViewDependence(graphic, nativePointSymbolScaling) {
8547
+ if (isPointSymbol(graphic)) {
8548
+ if (graphic.offset) return "view";
8549
+ return graphic.size.unit === "meters" && !nativePointSymbolScaling ? "resolution" : "none";
8550
+ }
8551
+ if (isImageGraphic(graphic)) return "none";
8552
+ return measureRendersFromViewState(graphic) ? "resolution" : "none";
8164
8553
  }
8165
8554
  function normalisePointEditModes(modes) {
8166
- for (const mode of modes ?? ["transform"]) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
8555
+ for (const mode of modes ?? ["transform"]) if (mode !== "transform" && mode !== "delete" && mode !== "offset") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
8167
8556
  }
8168
8557
  /**
8169
8558
  * Built-in default graphics style. Ensures OL / MapLibre / Leaflet render
@@ -8547,4 +8936,4 @@ function nearestWorldCopyPosition(position, refLng) {
8547
8936
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
8548
8937
  }
8549
8938
  //#endregion
8550
- export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, ImageCapabilityRequiredError, MINE_PATTERN_TILE_SIZE, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextHaloWidth, resolveTextSizePixels, tryControlMeasureIdFromFeature, validateImageGraphic };
8939
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, ImageCapabilityRequiredError, MINE_PATTERN_TILE_SIZE, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, alignPointSymbolIndicator, bindAbortable, cloneImageGraphic, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, convertPointSymbolPlacement, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, imageFootprint, imageOpacity, isAnchoredGraphic, isImageGraphic, isMinePattern, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, movePointSymbolLocation, nearestWorldCopyLng, nearestWorldCopyPosition, normalizeImageGraphic, pickHitTolerance, pickPointerType, placePointSymbol, pointSymbolFootprint, pointSymbolResource, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolvePointSymbolOffset, resolveTextHaloWidth, resolveTextSizePixels, scalePointSymbolLayout, tryControlMeasureIdFromFeature, validateImageGraphic };
@@ -6,17 +6,21 @@ interface PointerEventInfo {
6
6
  /** Per-event hit-tolerance override (e.g. larger for touch). */
7
7
  hitTolerance?: number;
8
8
  /**
9
- * Whether the Alt/Option modifier was held for this pointer event. The edit
10
- * controller reads this on pointer-down to turn a vertex hit into a delete
11
- * (the GIS-standard Alt+click-to-delete gesture) instead of a drag. Only the
12
- * mouse paths populate it; touch leaves it `undefined`.
9
+ * Whether Alt/Option was held. Used for Alt+click vertex deletion and the
10
+ * optional point-symbol offset shortcut. Modifiers are populated on mouse
11
+ * pointer-down; touch leaves them undefined.
13
12
  */
14
13
  altKey?: boolean;
15
14
  /**
16
15
  * Whether Shift was held for this pointer event. Rotation gestures use the
17
16
  * live value to constrain transform-box angles to 15-degree increments.
17
+ * Point-symbol offset shortcuts read their configured modifier on pointer-down.
18
18
  */
19
19
  shiftKey?: boolean;
20
+ /** Whether Control was held for this pointer event. */
21
+ ctrlKey?: boolean;
22
+ /** Whether Meta/Command was held for this pointer event. */
23
+ metaKey?: boolean;
20
24
  /** Engine-native event, for drivers that need to call preventDefault etc. */
21
25
  native?: unknown;
22
26
  }
@@ -1,4 +1,4 @@
1
- import { a as LayerId, o as MapAdapter, p as PixelCoordinate } from "./map-adapter-BWvKku2P.mjs";
1
+ import { a as LayerId, o as MapAdapter, p as PixelCoordinate } from "./map-adapter-CyVIAoPf.mjs";
2
2
  import { Feature } from "geojson";
3
3
 
4
4
  //#region src/testing/feature-group-conformance.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@orbat-mapper/control-measures": "^0.23.0"
42
+ "@orbat-mapper/control-measures": "^0.25.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "vitest": ">=4.0.0"