@orbat-mapper/tactical-draw 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_LABEL_HEIGHT_CSS_PIXELS, 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";
@@ -1878,7 +1878,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
1878
1878
  * kinds need no host UI.
1879
1879
  */
1880
1880
  function runFixedLengthDraw(options) {
1881
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, onSession, seed, snap } = options;
1881
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, symbolColors, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, onSession, seed, snap } = options;
1882
1882
  const metadata = getControlMeasureMetadata(kind);
1883
1883
  const rule = metadata.rule;
1884
1884
  const min = metadata.minCoordinates;
@@ -1887,7 +1887,10 @@ function runFixedLengthDraw(options) {
1887
1887
  if (rule) required = rule.minimumUserPoints;
1888
1888
  else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
1889
1889
  else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
1890
- const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
1890
+ const renderOpts = {
1891
+ graphicsStyle,
1892
+ symbolColors
1893
+ };
1891
1894
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1892
1895
  const isAreaKind = metadata.geometry === "area";
1893
1896
  const initialPoints = [];
@@ -2155,7 +2158,7 @@ const PREVIEW_CURSOR = "crosshair";
2155
2158
  * added point again" as additional gesture-level commit triggers.
2156
2159
  */
2157
2160
  function runVariableLengthDraw(options) {
2158
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, snap } = options;
2161
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, symbolColors, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, snap } = options;
2159
2162
  const metadata = getControlMeasureMetadata(kind);
2160
2163
  if (typeof metadata.minCoordinates !== "number") throw new Error(`runVariableLengthDraw: kind "${kind}" has no minCoordinates metadata`);
2161
2164
  const min = metadata.minCoordinates;
@@ -2173,7 +2176,10 @@ function runVariableLengthDraw(options) {
2173
2176
  function insertionIndex(len) {
2174
2177
  return canonicalFormed(len) ? len - trailingFixed : len;
2175
2178
  }
2176
- const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2179
+ const renderOpts = {
2180
+ graphicsStyle,
2181
+ symbolColors
2182
+ };
2177
2183
  const initialPoints = [];
2178
2184
  if (seed) {
2179
2185
  for (const pos of seed) {
@@ -2594,6 +2600,140 @@ function runEditInteraction(spec) {
2594
2600
  });
2595
2601
  }
2596
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
2597
2737
  //#region src/tactical-draw/handles.ts
2598
2738
  /**
2599
2739
  * Midpoint of the segment between two positions, in lonLat. The single source
@@ -2698,9 +2838,6 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
2698
2838
  };
2699
2839
  });
2700
2840
  }
2701
- function pixelDistance$2(a, b) {
2702
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2703
- }
2704
2841
  /**
2705
2842
  * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
2706
2843
  * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
@@ -2714,7 +2851,7 @@ function pixelDistance$2(a, b) {
2714
2851
  function pickHandle(handles, pixel, tolerance, toPixel) {
2715
2852
  for (const handle of handles) {
2716
2853
  const projected = toPixel(handle.position);
2717
- if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
2854
+ if (projected && pixelDistance$1(projected, pixel) <= tolerance) return handle;
2718
2855
  }
2719
2856
  return null;
2720
2857
  }
@@ -2732,139 +2869,6 @@ function constrainRotation(angle) {
2732
2869
  return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
2733
2870
  }
2734
2871
  //#endregion
2735
- //#region src/tactical-draw/transform-box.ts
2736
- /**
2737
- * The uniform scale factor and rotation delta a box implies relative to a
2738
- * pre-drag box, given the gesture kind. A `body` translate reports neither
2739
- * (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
2740
- * (folding the delta into rotation/size options); the delta is pivot-
2741
- * independent so either can apply it without knowing the box center.
2742
- */
2743
- function boxScaleRotation(kind, box, preDragBox) {
2744
- return {
2745
- scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
2746
- rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
2747
- };
2748
- }
2749
- function rotate(x, y, angle) {
2750
- const cos = Math.cos(angle);
2751
- const sin = Math.sin(angle);
2752
- return [x * cos - y * sin, x * sin + y * cos];
2753
- }
2754
- /**
2755
- * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
2756
- * Rotates the points by `-angle` about their centroid, takes the axis-aligned
2757
- * bounding box in that de-rotated frame, then re-expresses the result as an
2758
- * oriented box at `angle` — so the box always has exactly the requested tilt,
2759
- * sized to just contain the (de-rotated) points. Half-dimensions are floored
2760
- * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
2761
- * pickable box. Returns `null` for an empty list — there is nothing to fit.
2762
- *
2763
- * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
2764
- * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
2765
- * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
2766
- * the rendered geometry. Everything downstream that reads a fitted box
2767
- * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
2768
- * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
2769
- * placement, the rotate grip, and picking automatically — no other function
2770
- * needs to know about it.
2771
- */
2772
- function fitTransformBox(pixels, angle, padding = 0) {
2773
- if (pixels.length === 0) return null;
2774
- let cx = 0;
2775
- let cy = 0;
2776
- for (const p of pixels) {
2777
- cx += p[0];
2778
- cy += p[1];
2779
- }
2780
- cx /= pixels.length;
2781
- cy /= pixels.length;
2782
- let minX = Infinity;
2783
- let minY = Infinity;
2784
- let maxX = -Infinity;
2785
- let maxY = -Infinity;
2786
- for (const p of pixels) {
2787
- const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
2788
- if (rx < minX) minX = rx;
2789
- if (rx > maxX) maxX = rx;
2790
- if (ry < minY) minY = ry;
2791
- if (ry > maxY) maxY = ry;
2792
- }
2793
- const localCenterX = (minX + maxX) / 2;
2794
- const localCenterY = (minY + maxY) / 2;
2795
- const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
2796
- const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
2797
- const [ox, oy] = rotate(localCenterX, localCenterY, angle);
2798
- return {
2799
- center: [cx + ox, cy + oy],
2800
- halfWidth,
2801
- halfHeight,
2802
- angle
2803
- };
2804
- }
2805
- /**
2806
- * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
2807
- * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
2808
- * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
2809
- * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
2810
- * anchor is always the opposite corner, `(index + 2) % 4`.
2811
- */
2812
- function boxCorners(box) {
2813
- const { center, halfWidth, halfHeight, angle } = box;
2814
- return [
2815
- [-halfWidth, -halfHeight],
2816
- [halfWidth, -halfHeight],
2817
- [halfWidth, halfHeight],
2818
- [-halfWidth, halfHeight]
2819
- ].map(([x, y]) => {
2820
- const [rx, ry] = rotate(x, y, angle);
2821
- return [center[0] + rx, center[1] + ry];
2822
- });
2823
- }
2824
- /**
2825
- * Where the rotate grip sits: `offset` pixels (default `ROTATE_GRIP_OFFSET_PX`)
2826
- * above the top-edge midpoint, along the box's local "up" direction, so the
2827
- * grip stays glued to the box (rotates with it) rather than floating at a
2828
- * fixed screen offset. The label transform box passes its own tighter
2829
- * clearance (`LABEL_ROTATE_GRIP_OFFSET_PX`).
2830
- */
2831
- function rotateGripPixel(box, offset = 28) {
2832
- const { center, halfHeight, angle } = box;
2833
- const [rx, ry] = rotate(0, -(halfHeight + offset), angle);
2834
- return [center[0] + rx, center[1] + ry];
2835
- }
2836
- /**
2837
- * Point-in-oriented-rectangle test, used to start a body (translate) drag
2838
- * when the pointer goes down inside the box but not on a corner/rotate grip.
2839
- * De-rotates `pixel` into the box's local frame about `box.center` and
2840
- * compares against the half-extents.
2841
- */
2842
- function pointInBox(box, pixel) {
2843
- const { center, halfWidth, halfHeight, angle } = box;
2844
- const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
2845
- return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
2846
- }
2847
- function pixelDistance$1(a, b) {
2848
- return Math.hypot(a[0] - b[0], a[1] - b[1]);
2849
- }
2850
- /**
2851
- * Pick what part of the box, if any, `pixel` hits — corners first, then the
2852
- * rotate grip, then plain containment (body drag). Corners and the rotate
2853
- * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
2854
- * click just outside the box never starts a body drag. Returns `null` when
2855
- * nothing matches.
2856
- */
2857
- function pickBoxHandle(box, pixel, tolerance) {
2858
- const corners = boxCorners(box);
2859
- for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
2860
- kind: "scale",
2861
- index: i
2862
- };
2863
- if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
2864
- if (pointInBox(box, pixel)) return { kind: "body" };
2865
- return null;
2866
- }
2867
- //#endregion
2868
2872
  //#region src/tactical-draw/handle-drag.ts
2869
2873
  function clonePoints(points) {
2870
2874
  return points.map((p) => [...p]);
@@ -3914,8 +3918,11 @@ const ALLOWED_EDIT_MODES = new Set([
3914
3918
  * - close / abort / signal / preempt / destroy paths
3915
3919
  */
3916
3920
  function runEdit(opts) {
3917
- const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, settleToGraphics, publishWorkingGraphic, onSettled, onClickAwayGraphicsPick, snap } = opts;
3918
- const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
3921
+ const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, symbolColors, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, settleToGraphics, publishWorkingGraphic, onSettled, onClickAwayGraphicsPick, snap } = opts;
3922
+ const renderOpts = {
3923
+ graphicsStyle,
3924
+ symbolColors
3925
+ };
3919
3926
  let sizeAnchor = opts.sizeAnchor ?? defaultSizeAnchor(measure);
3920
3927
  const metadata = getControlMeasureMetadata(measure.kind);
3921
3928
  const rule = metadata.rule;
@@ -5181,7 +5188,7 @@ var ImageCapabilityRequiredError = class ImageCapabilityRequiredError extends Er
5181
5188
  }
5182
5189
  };
5183
5190
  //#endregion
5184
- //#region src/tactical-draw/point-symbol-rendering.ts
5191
+ //#region src/point-symbol-size.ts
5185
5192
  function effectivePointSymbolPixelSize(symbol, resolution) {
5186
5193
  if (symbol.size.unit === "pixels") return symbol.size.value;
5187
5194
  if (resolution === void 0 || resolution <= 0) return symbol.size.value;
@@ -5193,10 +5200,221 @@ function pointSymbolPixelScale(symbol, render, resolution) {
5193
5200
  if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
5194
5201
  return effectivePointSymbolPixelSize(symbol, resolution) / render.octagonSize;
5195
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
5196
5412
  /** Complete rotated logical footprint for selection and transform chrome. */
5197
5413
  function pointSymbolFootprint(symbol, render, adapter) {
5198
- const anchor = adapter.getPixelFromCoordinate(symbol.position);
5199
- 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;
5200
5418
  const scale = pointSymbolPixelScale(symbol, render, adapter.getResolution());
5201
5419
  const angle = symbol.rotation - adapter.getBearing();
5202
5420
  const centerOffsetX = (render.width / 2 - render.anchor.x) * scale;
@@ -5211,27 +5429,64 @@ function pointSymbolFootprint(symbol, render, adapter) {
5211
5429
  };
5212
5430
  return {
5213
5431
  box,
5214
- corners: boxCorners(box)
5432
+ corners: boxCorners(box),
5433
+ connector: layout.path
5215
5434
  };
5216
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
+ }
5217
5450
  function renderPointSymbolForAdapter(adapter, capability, symbol) {
5218
- const render = capability.render(symbol);
5219
- return {
5220
- render,
5221
- resource: {
5222
- id: render.resourceId,
5223
- svg: render.svg,
5224
- width: render.width,
5225
- height: render.height,
5226
- anchor: [render.anchor.x, render.anchor.y],
5227
- coreRect: {
5228
- x: render.octagonAnchor.x - render.octagonSize / 2,
5229
- y: render.octagonAnchor.y - render.octagonSize / 2,
5230
- width: render.octagonSize,
5231
- 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)
5232
5481
  }
5233
- },
5234
- 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)
5235
5490
  };
5236
5491
  }
5237
5492
  function pointSymbolFeature(symbol, render, resolution) {
@@ -5255,7 +5510,7 @@ function pointSymbolFeature(symbol, render, resolution) {
5255
5510
  iconId: render.resourceId,
5256
5511
  iconScale: pointSymbolPixelScale(symbol, render, resolution),
5257
5512
  iconRotation: symbol.rotation,
5258
- iconOpacity: symbol.style?.opacity ?? 1,
5513
+ iconOpacity: (symbol.style?.opacity ?? 1) * (symbol.style?.overallOpacity ?? 1),
5259
5514
  ...groundSizeProperties
5260
5515
  }
5261
5516
  };
@@ -5455,10 +5710,13 @@ function defaultGroupSizeAnchor(graphics) {
5455
5710
  * a live session without a session boundary
5456
5711
  */
5457
5712
  function runGroupTransform(opts) {
5458
- const { adapter, capability, imageCapability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, publishWorkingGraphics, settleWorkingGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
5713
+ const { adapter, capability, imageCapability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, symbolColors, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, publishWorkingGraphics, settleWorkingGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
5459
5714
  assertCapabilityFor(graphics);
5460
5715
  validateImageResources(graphics);
5461
- const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
5716
+ const renderOpts = {
5717
+ graphicsStyle,
5718
+ symbolColors
5719
+ };
5462
5720
  let sizeAnchor = opts.sizeAnchor ?? defaultGroupSizeAnchor(graphics);
5463
5721
  /** Throw synchronously if any graphic in `list` is a point symbol and no capability was supplied. */
5464
5722
  function assertCapabilityFor(list) {
@@ -5482,6 +5740,7 @@ function runGroupTransform(opts) {
5482
5740
  function createMemberState(graphic) {
5483
5741
  if (isAnchoredGraphic(graphic)) {
5484
5742
  const editStartPoints = [[...graphic.position]];
5743
+ if (isPointSymbol(graphic) && graphic.offset?.placement.mode === "ground") editStartPoints.push([...graphic.offset.placement.position]);
5485
5744
  return {
5486
5745
  graphic,
5487
5746
  editStartPoints,
@@ -5534,6 +5793,14 @@ function runGroupTransform(opts) {
5534
5793
  symbol.position = [...member.workingPoints[0]];
5535
5794
  symbol.size = resizePointSymbolSize(symbol.size, member.workingSize);
5536
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
+ }
5537
5804
  return symbol;
5538
5805
  }
5539
5806
  function buildWorkingImage(member) {
@@ -5724,7 +5991,7 @@ function runGroupTransform(opts) {
5724
5991
  const symbol = buildWorkingPointSymbol(member);
5725
5992
  const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
5726
5993
  iconResources = [plan.resource];
5727
- features = [plan.feature];
5994
+ features = plan.features;
5728
5995
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
5729
5996
  if (footprint) boxPixels = footprint.corners;
5730
5997
  } else {
@@ -6291,7 +6558,7 @@ function runPointSymbolDraw(options) {
6291
6558
  const plan = renderPointSymbolForAdapter(adapter, capability, build(PREVIEW_ID$1, latestPosition));
6292
6559
  ctx.writeLayer(previewLayer, [{
6293
6560
  id: PREVIEW_ID$1,
6294
- features: [plan.feature],
6561
+ features: plan.features,
6295
6562
  iconResources: [plan.resource]
6296
6563
  }]);
6297
6564
  };
@@ -6331,9 +6598,19 @@ function runPointSymbolDraw(options) {
6331
6598
  }
6332
6599
  //#endregion
6333
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
+ }
6334
6611
  function normalisePointModes(input) {
6335
6612
  const modes = input ?? ["transform"];
6336
- 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}"`);
6337
6614
  return modes.includes("delete") ? ["delete"] : [...new Set(modes)];
6338
6615
  }
6339
6616
  function symbolBox(adapter, symbol, rendered, padding = 0) {
@@ -6347,11 +6624,12 @@ function symbolBox(adapter, symbol, rendered, padding = 0) {
6347
6624
  }
6348
6625
  function runPointSymbolEdit(opts) {
6349
6626
  const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, publishWorkingGraphic, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
6350
- let modes = normalisePointModes(opts.modes);
6627
+ let modes = normalisePointModes(opts.modes ?? (symbol.offset ? ["transform", "offset"] : ["transform"]));
6351
6628
  let usesGraphicStack = wasOnGraphicsLayer;
6352
6629
  let working = clonePointSymbol(symbol);
6353
6630
  let dirty = false;
6354
6631
  let box = null;
6632
+ let currentLayout = null;
6355
6633
  let drag = null;
6356
6634
  let ctx;
6357
6635
  let driver;
@@ -6373,21 +6651,48 @@ function runPointSymbolEdit(opts) {
6373
6651
  const render = () => {
6374
6652
  if (ctx.settled) return;
6375
6653
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
6654
+ currentLayout = plan.layout;
6376
6655
  const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
6377
6656
  const handles = [];
6378
6657
  if (modes.includes("transform") && nextBox) {
6379
6658
  handles.push(boxOutlineFeature(nextBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
6380
6659
  handles.push(...toFeatures(boxToHandles(nextBox, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
6381
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
+ }
6382
6687
  publishWorkingGraphic({
6383
6688
  id: working.id,
6384
- features: [plan.feature],
6689
+ features: plan.features,
6385
6690
  iconResources: [plan.resource]
6386
6691
  });
6387
6692
  if (usesGraphicStack) ctx.writeLayer(previewLayer, []);
6388
6693
  else ctx.writeLayer(previewLayer, [{
6389
6694
  id: `__td_point_symbol_edit_preview__:${working.id}`,
6390
- features: [plan.feature],
6695
+ features: plan.features,
6391
6696
  iconResources: [plan.resource]
6392
6697
  }]);
6393
6698
  box = nextBox;
@@ -6437,6 +6742,37 @@ function runPointSymbolEdit(opts) {
6437
6742
  };
6438
6743
  const session = {
6439
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
+ },
6440
6776
  graphic: clonePointSymbol(symbol),
6441
6777
  get workingGraphic() {
6442
6778
  return clonePointSymbol(working);
@@ -6462,8 +6798,8 @@ function runPointSymbolEdit(opts) {
6462
6798
  if (ctx.settled) return;
6463
6799
  const next = updater(clonePointSymbol(working));
6464
6800
  if (next.kind !== "point-symbol" || next.id !== symbol.id) throw new TypeError("updateGraphic must preserve point-symbol kind and id");
6465
- capability.render(next);
6466
- 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");
6467
6803
  },
6468
6804
  onChange(listener) {
6469
6805
  if (ctx.settled) return () => {};
@@ -6479,41 +6815,87 @@ function runPointSymbolEdit(opts) {
6479
6815
  };
6480
6816
  const pointerAngle = (pixel, anchor) => Math.atan2(pixel[1] - anchor[1], pixel[0] - anchor[0]);
6481
6817
  const onDown = (pixel, info) => {
6482
- if (ctx.settled || !box || !modes.includes("transform")) return;
6483
- const hit = pickBoxHandle(box, pixel, info?.hitTolerance ?? 12);
6484
- if (!hit) return;
6485
- const anchor = driver.toPixel(working.position);
6486
- 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;
6487
6837
  drag = {
6488
- kind: hit.kind,
6838
+ kind,
6839
+ offsetMode,
6840
+ render,
6841
+ layout: structuredClone(currentLayout),
6842
+ snapped: false,
6489
6843
  down: [...pixel],
6490
6844
  start: clonePointSymbol(working),
6491
6845
  anchor,
6492
6846
  startPointerAngle: pointerAngle(pixel, anchor),
6493
- startDistance: Math.max(1, Math.hypot(pixel[0] - anchor[0], pixel[1] - anchor[1]))
6847
+ startDistance: Math.max(1, pixelDistance$1(pixel, anchor))
6494
6848
  };
6495
6849
  driver.setDragPanEnabled(false);
6496
6850
  };
6497
6851
  const transformed = (pixel, info) => {
6498
6852
  if (!drag) return null;
6499
- const next = clonePointSymbol(drag.start);
6500
- 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") {
6501
6876
  const unsnappedPosition = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
6502
6877
  const anchorPixel = driver.toPixel(unsnappedPosition) ?? pixel;
6503
- next.position = (snap?.({
6878
+ const position = (snap?.({
6504
6879
  coordinate: unsnappedPosition,
6505
6880
  pixel: anchorPixel,
6506
6881
  interaction: "translate",
6507
6882
  graphicId: symbol.id,
6508
6883
  originalEvent: info?.native
6509
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
+ }
6510
6891
  } else if (drag.kind === "rotate") {
6511
6892
  const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
6512
6893
  next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
6513
6894
  } else {
6514
- const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
6895
+ const distance = pixelDistance$1(pixel, drag.anchor);
6515
6896
  next.size = resizePointSymbolSize(drag.start.size, Math.max(.1, drag.start.size.value * (distance / drag.startDistance)));
6516
6897
  }
6898
+ if (drag.kind === "rotate" || drag.kind === "scale") next = preserveDisplayPlacement(next, capability.render(next), adapter, drag.layout);
6517
6899
  return next;
6518
6900
  };
6519
6901
  const onMove = (pixel, info) => {
@@ -6982,8 +7364,10 @@ var TacticalDraw = class {
6982
7364
  handlesLayer;
6983
7365
  ownedLayers;
6984
7366
  graphicsStyle;
7367
+ symbolColors;
6985
7368
  defaultInteractionStyle;
6986
7369
  generateId;
7370
+ offsetDragModifier;
6987
7371
  pointSymbols;
6988
7372
  images;
6989
7373
  snappingOptions;
@@ -7034,6 +7418,8 @@ var TacticalDraw = class {
7034
7418
  transformCounter = 0;
7035
7419
  /** Snapshot of the host's latest authoritative Graphic membership and order. */
7036
7420
  lastRenderedMeasures = [];
7421
+ /** Whether any last-rendered graphic reads the whole camera, not just scale. */
7422
+ lastRenderedHasViewDependent = false;
7037
7423
  snapGeometryFeatures = [];
7038
7424
  /**
7039
7425
  * Rendered graphics-layer groups, keyed on Graphic snapshot identity. Each
@@ -7072,6 +7458,8 @@ var TacticalDraw = class {
7072
7458
  hasHighlightOutlines = false;
7073
7459
  constructor(adapter, options) {
7074
7460
  this.adapter = adapter;
7461
+ this.offsetDragModifier = options?.offsetDragModifier ?? false;
7462
+ this.symbolColors = options?.symbolColors ? { ...options.symbolColors } : void 0;
7075
7463
  this.lastViewResolution = adapter.getResolution();
7076
7464
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
7077
7465
  this.defaultInteractionStyle = options?.interactionStyle;
@@ -7097,6 +7485,11 @@ var TacticalDraw = class {
7097
7485
  this.adapter.setLayerStyle(this.highlightLayer, guideToStyleOptions(this.baseHighlightStyle()));
7098
7486
  this.adapter.onViewChange(this.handleViewChange);
7099
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
+ }
7100
7493
  /** Replace snapping configuration without recreating the map adapter or façade. */
7101
7494
  setSnappingOptions(options) {
7102
7495
  this.assertAlive();
@@ -7127,15 +7520,15 @@ var TacticalDraw = class {
7127
7520
  renderGraphicGroup(graphic) {
7128
7521
  const cached = this.graphicGroupCache.get(graphic);
7129
7522
  if (cached) {
7130
- if (!cached.resolutionDependent) return cached.group;
7131
- 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;
7132
7525
  }
7133
- const resolutionDependent = graphicRendersFromViewState(graphic, this.adapter.scalesGroundPointSymbolsNatively === true);
7134
- 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();
7135
7528
  const group = this.buildGraphicGroup(graphic);
7136
7529
  this.graphicGroupCache.set(graphic, {
7137
7530
  group,
7138
- resolutionDependent,
7531
+ dependence,
7139
7532
  resolution
7140
7533
  });
7141
7534
  return group;
@@ -7152,7 +7545,7 @@ var TacticalDraw = class {
7152
7545
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
7153
7546
  return {
7154
7547
  id: graphic.id,
7155
- features: [plan.feature],
7548
+ features: plan.features,
7156
7549
  iconResources: [plan.resource],
7157
7550
  revision
7158
7551
  };
@@ -7168,7 +7561,10 @@ var TacticalDraw = class {
7168
7561
  revision
7169
7562
  };
7170
7563
  }
7171
- const render = renderMeasureForAdapter(this.adapter, graphic, { graphicsStyle: this.graphicsStyle });
7564
+ const render = renderMeasureForAdapter(this.adapter, graphic, {
7565
+ graphicsStyle: this.graphicsStyle,
7566
+ symbolColors: this.symbolColors
7567
+ });
7172
7568
  return {
7173
7569
  id: graphic.id,
7174
7570
  features: render.features,
@@ -7230,11 +7626,11 @@ var TacticalDraw = class {
7230
7626
  this.lastViewResolution = resolution;
7231
7627
  if (!this.hasRendered) return;
7232
7628
  this.renderHighlights();
7233
- if (!resolutionChanged) return;
7629
+ const nativePointSymbolScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
7630
+ if (!resolutionChanged && !this.lastRenderedHasViewDependent) return;
7234
7631
  const excluded = this.graphicsStackExcludedIds();
7235
7632
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
7236
- const nativePointSymbolScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
7237
- if (!visible.some((g) => graphicRendersFromViewState(g, nativePointSymbolScaling))) return;
7633
+ if (!visible.some((g) => graphicViewDependence(g, nativePointSymbolScaling) !== "none")) return;
7238
7634
  this.reconcileGraphicsStack(visible);
7239
7635
  };
7240
7636
  /**
@@ -7318,6 +7714,7 @@ var TacticalDraw = class {
7318
7714
  }
7319
7715
  }
7320
7716
  this.lastRenderedMeasures = measures.slice();
7717
+ this.lastRenderedHasViewDependent = this.lastRenderedMeasures.some((g) => graphicViewDependence(g, this.adapter.scalesGroundPointSymbolsNatively === true) === "view");
7321
7718
  this.graphicStack.acknowledgeHostRender(measures);
7322
7719
  if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && graphic.kind === activeEdit.graphicKind)) activeEdit.syncHostMembership(true);
7323
7720
  (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
@@ -7412,7 +7809,10 @@ var TacticalDraw = class {
7412
7809
  if (!this.images) return null;
7413
7810
  return imageFootprint(graphic, renderImageForAdapter(this.adapter, this.images, graphic).render, this.adapter)?.box ?? null;
7414
7811
  }
7415
- const render = renderMeasureForAdapter(this.adapter, graphic, { graphicsStyle: this.graphicsStyle });
7812
+ const render = renderMeasureForAdapter(this.adapter, graphic, {
7813
+ graphicsStyle: this.graphicsStyle,
7814
+ symbolColors: this.symbolColors
7815
+ });
7416
7816
  if (graphic.kind === "text") return fitTextFootprintFromFeatures(render.features, (lonLat) => this.adapter.getPixelFromCoordinate(lonLat), {
7417
7817
  resolution: this.adapter.getResolution(),
7418
7818
  bearing
@@ -7576,6 +7976,7 @@ var TacticalDraw = class {
7576
7976
  previewLayer: this.previewLayer,
7577
7977
  guideLayer: this.guideLayer,
7578
7978
  graphicsStyle: this.graphicsStyle,
7979
+ symbolColors: this.symbolColors,
7579
7980
  generateId: () => measureDraft.id ?? this.generateId(),
7580
7981
  signal: options?.signal,
7581
7982
  measureOptions: measureDraft.options,
@@ -7594,6 +7995,7 @@ var TacticalDraw = class {
7594
7995
  previewLayer: this.previewLayer,
7595
7996
  guideLayer: this.guideLayer,
7596
7997
  graphicsStyle: this.graphicsStyle,
7998
+ symbolColors: this.symbolColors,
7597
7999
  generateId: () => measureDraft.id ?? this.generateId(),
7598
8000
  signal: options?.signal,
7599
8001
  onSession: this.trackSession(options?.onSession),
@@ -7664,6 +8066,7 @@ var TacticalDraw = class {
7664
8066
  adapter: this.adapter,
7665
8067
  capability: this.pointSymbols,
7666
8068
  symbol: measure,
8069
+ offsetDragModifier: () => options?.offsetDragModifier ?? this.offsetDragModifier,
7667
8070
  graphicsLayer: this.graphicsLayer,
7668
8071
  previewLayer: this.previewLayer,
7669
8072
  handlesLayer: this.handlesLayer,
@@ -7710,6 +8113,7 @@ var TacticalDraw = class {
7710
8113
  guideLayer: this.guideLayer,
7711
8114
  handlesLayer: this.handlesLayer,
7712
8115
  graphicsStyle: this.graphicsStyle,
8116
+ symbolColors: this.symbolColors,
7713
8117
  signal: options?.signal,
7714
8118
  closeOnClickAway: options?.closeOnClickAway,
7715
8119
  modes: options?.modes,
@@ -7764,6 +8168,7 @@ var TacticalDraw = class {
7764
8168
  previewLayer: this.previewLayer,
7765
8169
  handlesLayer: this.handlesLayer,
7766
8170
  graphicsStyle: this.graphicsStyle,
8171
+ symbolColors: this.symbolColors,
7767
8172
  signal: options?.signal,
7768
8173
  closeOnClickAway: options?.closeOnClickAway,
7769
8174
  interactionStyle: mergedInteractionStyle,
@@ -8133,23 +8538,28 @@ var TacticalDraw = class {
8133
8538
  if (this.destroyed) throw new TacticalDrawDestroyedError();
8134
8539
  }
8135
8540
  };
8136
- function graphicRendersFromViewState(graphic, nativePointSymbolScaling) {
8137
- if (isPointSymbol(graphic)) return graphic.size.unit === "meters" && !nativePointSymbolScaling;
8138
- if (isImageGraphic(graphic)) return false;
8139
- 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";
8140
8553
  }
8141
8554
  function normalisePointEditModes(modes) {
8142
- 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}"`);
8143
8556
  }
8144
8557
  /**
8145
8558
  * Built-in default graphics style. Ensures OL / MapLibre / Leaflet render
8146
8559
  * identically when neither host nor measure supplies a style — without it,
8147
8560
  * each engine falls through to its own native (and different) vector default.
8148
8561
  */
8149
- const BUILT_IN_GRAPHICS_STYLE = {
8150
- color: "#000000",
8151
- strokeWidth: 2
8152
- };
8562
+ const BUILT_IN_GRAPHICS_STYLE = { strokeWidth: 2 };
8153
8563
  /**
8154
8564
  * Built-in defaults for the interaction-affordance slots (ADR-0006, extended
8155
8565
  * for the ADR-0017 transform box). The scale (corner) grips and the rotate
@@ -8237,7 +8647,7 @@ function mergeSlot(base, override) {
8237
8647
  }
8238
8648
  function styleToStyleOptions(style) {
8239
8649
  const out = {};
8240
- const strokeColor = style.strokeColor ?? style.color;
8650
+ const strokeColor = style.strokeColor ?? style.color ?? DEFAULT_SYMBOL_COLOR;
8241
8651
  if (strokeColor !== void 0) out.strokeColor = strokeColor;
8242
8652
  if (style.strokeWidth !== void 0) out.strokeWidth = style.strokeWidth;
8243
8653
  if (style.strokeDash !== void 0) out.strokeDash = [...style.strokeDash];
@@ -8526,4 +8936,4 @@ function nearestWorldCopyPosition(position, refLng) {
8526
8936
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
8527
8937
  }
8528
8938
  //#endregion
8529
- 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 };