@orbat-mapper/tactical-draw 0.2.0-alpha.37 → 0.2.0-alpha.39

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 { S as PointerEventInfo, _ as fillPatternImageDimension, a as MapAdapter, b as EditPointerDriver, c as MapEventHandler, d as PickOptions, f as PixelCoordinate, g as HatchPattern, h as HATCH_PATTERNS, i as LayerId, l as PickEvent, m as TextStyleOptions, n as HandleStyle, o as MapCursor, p as StyleOptions, r as IconResource, s as MapEvent, t as FeatureGroup, u as PickHandler, v as fillPatternImageId, x as PointerCallback, y as makeFillPatternImage } from "./map-adapter-meRo5VY2.mjs";
1
+ import { S as PointerEventInfo, _ as fillPatternImageDimension, a as MapAdapter, b as EditPointerDriver, c as MapEventHandler, d as PickOptions, f as PixelCoordinate, g as HatchPattern, h as HATCH_PATTERNS, i as LayerId, l as PickEvent, m as TextStyleOptions, n as HandleStyle, o as MapCursor, p as StyleOptions, r as IconResource, s as MapEvent, t as FeatureGroup, u as PickHandler, v as fillPatternImageId, x as PointerCallback, y as makeFillPatternImage } from "./map-adapter-84irgVJR.mjs";
2
2
  import { AmplifierPlacement, AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureRender, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
3
3
  import { Feature, Position } from "geojson";
4
4
 
@@ -400,6 +400,15 @@ interface PointSymbolCapability {
400
400
  readonly defaultRotation: number;
401
401
  render(symbol: PointSymbol): PointSymbolRender;
402
402
  invalidate(): void;
403
+ /**
404
+ * Monotonic counter that increments on each `invalidate()` call. Consumers
405
+ * (e.g. a render cache keyed on graphic snapshot identity) may read this to
406
+ * detect that previously rendered point-symbol output is stale even though
407
+ * the graphic snapshot itself is unchanged. Optional — when absent,
408
+ * invalidation cannot be detected by cache layers, so they must not attempt
409
+ * to key on it.
410
+ */
411
+ readonly generation?: number;
403
412
  }
404
413
  declare function isPointSymbol(graphic: unknown): graphic is PointSymbol;
405
414
  declare function clonePointSymbol(symbol: PointSymbol): PointSymbol;
@@ -616,7 +625,15 @@ interface EditSession {
616
625
  readonly history: SessionHistory;
617
626
  /** Immutable edit-start input. */
618
627
  readonly graphic: ControlMeasure;
619
- /** Live complete working graphic. Copy on read. */
628
+ /**
629
+ * Live complete working graphic. Copy on read. `properties` (host-owned
630
+ * metadata, e.g. `standardIdentity`/`colorMode`) is session-owned like every
631
+ * other working field: it starts at the edit-start value, is replaced
632
+ * wholesale by [[updateGraphic]], survives every subsequent gesture/setter
633
+ * call and in-session undo/redo, and is rolled back by `event.reject()`
634
+ * (issue #354). Absent when the edit-start measure carried none and
635
+ * [[updateGraphic]] has not set any since.
636
+ */
620
637
  readonly workingGraphic: ControlMeasure;
621
638
  /** Live working state. Copy on read. */
622
639
  readonly controlPoints: readonly Position[];
@@ -1443,6 +1460,23 @@ declare class TacticalDraw {
1443
1460
  /** Snapshot of the host's latest authoritative Graphic membership and order. */
1444
1461
  private lastRenderedMeasures;
1445
1462
  private snapGeometryFeatures;
1463
+ /**
1464
+ * Rendered graphics-layer groups, keyed on Graphic snapshot identity. See
1465
+ * {@link renderGraphicGroup} for the invalidation contract; swapped for a
1466
+ * fresh map (not cleared entry-by-entry) so stale entries are collectable.
1467
+ */
1468
+ private graphicGroupCache;
1469
+ /**
1470
+ * Last {@link PointSymbolCapability.generation} observed from `pointSymbols`,
1471
+ * initialised from the capability at construction. Compared at the top of
1472
+ * every {@link reconcileGraphicsStack} so a host calling `invalidate()` after
1473
+ * changing renderer-global config (e.g. milsymbol options) drops the render
1474
+ * cache even though the Graphic snapshots handed to `render()` are
1475
+ * unchanged — otherwise the cache would keep serving pre-invalidation
1476
+ * output and the capability would never be re-consulted. `undefined` when
1477
+ * there is no capability, or the capability doesn't expose `generation`.
1478
+ */
1479
+ private lastSeenPointSymbolGeneration;
1446
1480
  /**
1447
1481
  * Ids of graphics currently carrying a passive selection-highlight outline
1448
1482
  * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
@@ -1462,8 +1496,34 @@ declare class TacticalDraw {
1462
1496
  setSnappingOptions(options?: SnappingOptions): void;
1463
1497
  /** Clear transient host affordances when snapping or its interaction ends. */
1464
1498
  private clearSnapResult;
1465
- /** Render one atomic Graphic group for the engine-neutral graphics stack. */
1499
+ /**
1500
+ * Render one atomic Graphic group for the engine-neutral graphics stack,
1501
+ * reusing the previous render when nothing that feeds it has changed.
1502
+ *
1503
+ * The cache is keyed on the Graphic snapshot's object identity: hosts hand
1504
+ * `render()` a fresh object whenever a graphic's data changes, so a changed
1505
+ * graphic misses by construction. (A host that mutates a Graphic in place and
1506
+ * re-renders the same reference is outside the contract — `render()` has
1507
+ * always been documented as taking snapshots.) Rendered output also depends
1508
+ * on live view state and on the resolved graphics style; the style is fixed
1509
+ * at construction, and every view change drops the whole cache. Crucially,
1510
+ * the view cannot change during a box drag (drag-pan is disabled), so every
1511
+ * graphic outside the selection stays a cache hit for the whole gesture and
1512
+ * per-frame reconcile cost becomes O(selection) rather than O(document).
1513
+ */
1466
1514
  private renderGraphicGroup;
1515
+ /**
1516
+ * Render a Graphic group from scratch and stamp it with a fresh revision,
1517
+ * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
1518
+ * its doc comment for why it isn't a per-instance field).
1519
+ */
1520
+ private buildGraphicGroup;
1521
+ /**
1522
+ * Drop every cached render. Called whenever an input shared by all graphic
1523
+ * renders changes — the live view state, or (via {@link reconcileGraphicsStack})
1524
+ * the point-symbol capability's `generation`.
1525
+ */
1526
+ private invalidateGraphicGroupCache;
1467
1527
  /** Submit one complete grouped desired state for the host stack plus overrides. */
1468
1528
  private reconcileGraphicsStack;
1469
1529
  /** Atomically publish several live renders into their host-owned Graphic slots. */
package/dist/index.mjs CHANGED
@@ -486,7 +486,8 @@ var BaseMapAdapter = class {
486
486
  const nextGroups = groups.map((group) => ({
487
487
  id: group.id,
488
488
  features: [...group.features],
489
- iconResources: [...group.iconResources]
489
+ iconResources: [...group.iconResources],
490
+ revision: group.revision
490
491
  }));
491
492
  const nextFeatures = nextGroups.flatMap((group) => group.features);
492
493
  const nextResourcesById = /* @__PURE__ */ new Map();
@@ -2923,6 +2924,200 @@ function beginBoxDrag(args) {
2923
2924
  };
2924
2925
  }
2925
2926
  //#endregion
2927
+ //#region src/tactical-draw/deep-equal.ts
2928
+ /**
2929
+ * @internal
2930
+ *
2931
+ * Structural equality for structured-cloneable values. Not exported from
2932
+ * `src/index.ts` — this is a private helper for comparing session-owned host
2933
+ * data (e.g. `ControlMeasure.properties`, `PointSymbol`) where `JSON.stringify`
2934
+ * is unsafe: it throws on `bigint` and cyclic graphs, and it silently
2935
+ * collapses values it cannot represent (`{x: undefined}` and `{}` both
2936
+ * stringify to `"{}"`; a `Map` stringifies to `"{}"` regardless of contents;
2937
+ * distinct `Date`s can coerce to the same string). See issue #354 review
2938
+ * follow-up.
2939
+ *
2940
+ * Design notes / deliberate limitations:
2941
+ * - Uses `Object.is` as the base-case comparator, so `NaN` equals `NaN` but
2942
+ * `0` and `-0` are treated as different values. This mirrors the semantics
2943
+ * most callers actually want for "did this change" checks (a NaN-valued
2944
+ * field shouldn't perpetually look dirty), and is cheap/well-defined; it
2945
+ * is a deliberate choice, not an oversight.
2946
+ * - Object comparison is key-set sensitive: `{}` and `{ x: undefined }` are
2947
+ * NOT equal, because they have different keys, even though the values at
2948
+ * key `x` compare loosely-equal-ish. This is required for `updateGraphic`
2949
+ * dedupe correctness — a caller that explicitly adds an `undefined`-valued
2950
+ * key is making a distinguishable structural edit.
2951
+ * - Only "own enumerable string keys" are compared for plain objects
2952
+ * (`Object.keys`), matching what `structuredClone` round-trips and what
2953
+ * hosts can observe with normal iteration. Symbol-keyed properties are not
2954
+ * considered (functions and symbols aren't structured-cloneable anyway).
2955
+ * - `Set` members and `Map` keys/values are matched with a proper one-to-one
2956
+ * (bipartite) matching, not a greedy or order-dependent search: primitive
2957
+ * members/keys take an O(1) `has`/`get` fast path, and any remaining
2958
+ * non-primitive members are matched with an augmenting-path search over
2959
+ * the candidate deep-equal pairs (Kuhn's algorithm), which is O(n*m) in
2960
+ * the number of non-primitive members/keys but correct in adversarial
2961
+ * cases with multiple structurally-equal-but-distinct candidates.
2962
+ * - Functions compare by reference (the `Object.is` fast path) and are
2963
+ * otherwise unequal — `structuredClone` rejects functions outright, so any
2964
+ * function-valued field here has already failed to round-trip through the
2965
+ * session's clone boundary.
2966
+ * - Cycle-safe: a stack of visited pairs is tracked so a self-referential
2967
+ * graph terminates (returns a comparison result) instead of overflowing.
2968
+ * The pair association is a bijection — an object already paired with a
2969
+ * *different* counterpart along this comparison path is rejected, not
2970
+ * just re-accepted because the exact pair recurred.
2971
+ * - `Error` (and subclasses, including `AggregateError`) are special-cased:
2972
+ * `name`, `message`, and `cause` are compared (`cause` is key-set
2973
+ * sensitive too — an absent `cause` differs from `cause: undefined`),
2974
+ * `AggregateError.errors` is compared as an array, plus any own
2975
+ * enumerable properties as usual. `stack` is deliberately IGNORED: it's
2976
+ * environment-dependent noise (file/line info), and two structurally
2977
+ * identical errors constructed at different call sites shouldn't read as
2978
+ * a host edit.
2979
+ */
2980
+ function tag(value) {
2981
+ return Object.prototype.toString.call(value);
2982
+ }
2983
+ function isTypedArrayOrDataView(value) {
2984
+ return ArrayBuffer.isView(value);
2985
+ }
2986
+ function bytesOf(value) {
2987
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
2988
+ }
2989
+ function bytesEqual(a, b) {
2990
+ if (a.length !== b.length) return false;
2991
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2992
+ return true;
2993
+ }
2994
+ function isPrimitive(value) {
2995
+ return typeof value !== "object" || value === null;
2996
+ }
2997
+ /**
2998
+ * Does a perfect one-to-one matching exist between the `leftCount` left-side
2999
+ * indices and `rightCount` right-side indices, given `canMatch(i, j)`?
3000
+ * Kuhn's augmenting-path algorithm — O(leftCount * rightCount * edges), fine
3001
+ * for the small collections (`Set`/`Map` members in a host properties bag)
3002
+ * this is used for.
3003
+ */
3004
+ function hasPerfectMatching(leftCount, rightCount, canMatch) {
3005
+ if (leftCount !== rightCount) return false;
3006
+ const matchOfRight = new Array(rightCount).fill(-1);
3007
+ function tryAugment(i, visited) {
3008
+ for (let j = 0; j < rightCount; j++) {
3009
+ if (visited[j] || !canMatch(i, j)) continue;
3010
+ visited[j] = true;
3011
+ if (matchOfRight[j] === -1 || tryAugment(matchOfRight[j], visited)) {
3012
+ matchOfRight[j] = i;
3013
+ return true;
3014
+ }
3015
+ }
3016
+ return false;
3017
+ }
3018
+ for (let i = 0; i < leftCount; i++) if (!tryAugment(i, new Array(rightCount).fill(false))) return false;
3019
+ return true;
3020
+ }
3021
+ /**
3022
+ * Deep structural equality for structured-cloneable values (plus `bigint`,
3023
+ * `symbol`, and functions, which compare by identity). See the module
3024
+ * doc-comment above for the exact rules and deliberate limitations.
3025
+ */
3026
+ function deepEqual(a, b) {
3027
+ return deepEqualInner(a, b, []);
3028
+ }
3029
+ function deepEqualInner(a, b, visited) {
3030
+ if (Object.is(a, b)) return true;
3031
+ if (typeof a !== typeof b) return false;
3032
+ if (a === null || b === null) return false;
3033
+ if (typeof a !== "object") return false;
3034
+ const objA = a;
3035
+ const objB = b;
3036
+ if (Object.getPrototypeOf(objA) !== Object.getPrototypeOf(objB)) return false;
3037
+ if (tag(objA) !== tag(objB)) return false;
3038
+ for (const [seenA, seenB] of visited) {
3039
+ if (seenA === objA && seenB === objB) return true;
3040
+ if (seenA === objA || seenB === objB) return false;
3041
+ }
3042
+ visited.push([objA, objB]);
3043
+ try {
3044
+ if (objA instanceof Date && objB instanceof Date) return Object.is(objA.getTime(), objB.getTime());
3045
+ if (objA instanceof RegExp && objB instanceof RegExp) return objA.source === objB.source && objA.flags === objB.flags;
3046
+ if (objA instanceof Error && objB instanceof Error) {
3047
+ if (objA.name !== objB.name) return false;
3048
+ if (objA.message !== objB.message) return false;
3049
+ const hasCauseA = Object.prototype.hasOwnProperty.call(objA, "cause");
3050
+ if (hasCauseA !== Object.prototype.hasOwnProperty.call(objB, "cause")) return false;
3051
+ if (hasCauseA && !deepEqualInner(objA.cause, objB.cause, visited)) return false;
3052
+ if (objA instanceof AggregateError) {
3053
+ if (!deepEqualInner(objA.errors, objB.errors, visited)) return false;
3054
+ }
3055
+ const keysA = Object.keys(objA);
3056
+ const keysB = Object.keys(objB);
3057
+ if (keysA.length !== keysB.length) return false;
3058
+ for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
3059
+ for (const key of keysA) {
3060
+ const valueA = objA[key];
3061
+ const valueB = objB[key];
3062
+ if (!deepEqualInner(valueA, valueB, visited)) return false;
3063
+ }
3064
+ return true;
3065
+ }
3066
+ if (isTypedArrayOrDataView(objA) && isTypedArrayOrDataView(objB)) return bytesEqual(bytesOf(objA), bytesOf(objB));
3067
+ if (objA instanceof ArrayBuffer && objB instanceof ArrayBuffer) return bytesEqual(new Uint8Array(objA), new Uint8Array(objB));
3068
+ if (objA instanceof Set && objB instanceof Set) {
3069
+ if (objA.size !== objB.size) return false;
3070
+ const remainingA = [];
3071
+ const remainingB = new Set(objB);
3072
+ for (const itemA of objA) {
3073
+ if (isPrimitive(itemA) && remainingB.has(itemA)) {
3074
+ remainingB.delete(itemA);
3075
+ continue;
3076
+ }
3077
+ remainingA.push(itemA);
3078
+ }
3079
+ const remainingBArr = [...remainingB];
3080
+ return hasPerfectMatching(remainingA.length, remainingBArr.length, (i, j) => deepEqualInner(remainingA[i], remainingBArr[j], visited));
3081
+ }
3082
+ if (objA instanceof Map && objB instanceof Map) {
3083
+ if (objA.size !== objB.size) return false;
3084
+ const remainingA = [];
3085
+ const remainingB = new Map(objB);
3086
+ for (const [keyA, valueA] of objA) {
3087
+ if (isPrimitive(keyA) && remainingB.has(keyA)) {
3088
+ if (!deepEqualInner(valueA, remainingB.get(keyA), visited)) return false;
3089
+ remainingB.delete(keyA);
3090
+ continue;
3091
+ }
3092
+ remainingA.push([keyA, valueA]);
3093
+ }
3094
+ const remainingBArr = [...remainingB];
3095
+ return hasPerfectMatching(remainingA.length, remainingBArr.length, (i, j) => {
3096
+ const [keyA, valueA] = remainingA[i];
3097
+ const [keyB, valueB] = remainingBArr[j];
3098
+ return deepEqualInner(keyA, keyB, visited) && deepEqualInner(valueA, valueB, visited);
3099
+ });
3100
+ }
3101
+ if (Array.isArray(objA) && Array.isArray(objB)) {
3102
+ if (objA.length !== objB.length) return false;
3103
+ for (let i = 0; i < objA.length; i++) if (!deepEqualInner(objA[i], objB[i], visited)) return false;
3104
+ return true;
3105
+ }
3106
+ const keysA = Object.keys(objA);
3107
+ const keysB = Object.keys(objB);
3108
+ if (keysA.length !== keysB.length) return false;
3109
+ for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
3110
+ for (const key of keysA) {
3111
+ const valueA = objA[key];
3112
+ const valueB = objB[key];
3113
+ if (!deepEqualInner(valueA, valueB, visited)) return false;
3114
+ }
3115
+ return true;
3116
+ } finally {
3117
+ visited.pop();
3118
+ }
3119
+ }
3120
+ //#endregion
2926
3121
  //#region src/tactical-draw/session-history.ts
2927
3122
  /** @internal Controller-owned history stack. */
2928
3123
  function createSessionHistory(options) {
@@ -3611,9 +3806,28 @@ function runEdit(opts) {
3611
3806
  let workingOptions = { ...measure.options ?? {} };
3612
3807
  let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
3613
3808
  let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
3809
+ let workingProperties = measure.properties ? structuredClone(measure.properties) : void 0;
3614
3810
  function snapshotAmplifierPlacements() {
3615
3811
  return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
3616
3812
  }
3813
+ function snapshotProperties() {
3814
+ return workingProperties ? structuredClone(workingProperties) : void 0;
3815
+ }
3816
+ /**
3817
+ * Dedupe comparison for the library's own typed session data — `options`,
3818
+ * `style`, `textAmplifiers`, `amplifierPlacements`. Those are declared as
3819
+ * JSON-representable values, so serialising them is safe and cheaper than a
3820
+ * structural walk.
3821
+ *
3822
+ * Deliberately NOT for anything that can carry `properties`: that bag is
3823
+ * `Record<string, unknown>` cloned with `structuredClone`, so a host may put
3824
+ * a bigint or a cyclic graph in it (throws here) or a `Map`/`undefined`-valued
3825
+ * key in it (silently collapses to the same string). Those comparisons use
3826
+ * `deepEqual` — see `./deep-equal` and issue #354.
3827
+ */
3828
+ function jsonEqual(a, b) {
3829
+ return JSON.stringify(a) === JSON.stringify(b);
3830
+ }
3617
3831
  let modes = requestedModes;
3618
3832
  let dirty = false;
3619
3833
  let nonGeometryDirty = false;
@@ -3684,6 +3898,7 @@ function runEdit(opts) {
3684
3898
  style: currentStyle ? { ...currentStyle } : void 0,
3685
3899
  textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
3686
3900
  amplifierPlacements: snapshotAmplifierPlacements(),
3901
+ properties: snapshotProperties(),
3687
3902
  sizeAnchor,
3688
3903
  nonGeometryDirty,
3689
3904
  boxAngle
@@ -3695,6 +3910,7 @@ function runEdit(opts) {
3695
3910
  currentStyle = value.style ? { ...value.style } : void 0;
3696
3911
  workingTextAmplifiers = value.textAmplifiers ? { ...value.textAmplifiers } : void 0;
3697
3912
  workingAmplifierPlacements = value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0;
3913
+ workingProperties = value.properties ? structuredClone(value.properties) : void 0;
3698
3914
  sizeAnchor = value.sizeAnchor;
3699
3915
  nonGeometryDirty = value.nonGeometryDirty;
3700
3916
  boxAngle = value.boxAngle;
@@ -3781,6 +3997,8 @@ function runEdit(opts) {
3781
3997
  else delete working.textAmplifiers;
3782
3998
  if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
3783
3999
  else delete working.amplifierPlacements;
4000
+ if (workingProperties) working.properties = workingProperties;
4001
+ else delete working.properties;
3784
4002
  let features;
3785
4003
  try {
3786
4004
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -3858,6 +4076,8 @@ function runEdit(opts) {
3858
4076
  else delete working.textAmplifiers;
3859
4077
  if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
3860
4078
  else delete working.amplifierPlacements;
4079
+ if (workingProperties) working.properties = workingProperties;
4080
+ else delete working.properties;
3861
4081
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
3862
4082
  return {
3863
4083
  graphic: cloneControlMeasure(committed),
@@ -3903,7 +4123,7 @@ function runEdit(opts) {
3903
4123
  * the history record. The deferred reject/close/abort machinery, exception
3904
4124
  * collection, and record-before-rethrow ordering live in the emitter.
3905
4125
  */
3906
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source = "gesture", historyLabel = "Edit", previousBoxAngle = boxAngle) {
4126
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, source = "gesture", historyLabel = "Edit", previousBoxAngle = boxAngle) {
3907
4127
  changeEmitter.emit({
3908
4128
  source,
3909
4129
  graphic: currentRendered,
@@ -3917,6 +4137,7 @@ function runEdit(opts) {
3917
4137
  sizeAnchor = previousSizeAnchor;
3918
4138
  workingTextAmplifiers = previousTextAmplifiers;
3919
4139
  workingAmplifierPlacements = previousAmplifierPlacements;
4140
+ workingProperties = previousProperties;
3920
4141
  boxAngle = previousBoxAngle;
3921
4142
  nonGeometryDirty = previousNonGeometryDirty;
3922
4143
  renderWorkingGraphic();
@@ -3944,6 +4165,7 @@ function runEdit(opts) {
3944
4165
  const previousNonGeometryDirty = nonGeometryDirty;
3945
4166
  const previousTextAmplifiers = workingTextAmplifiers;
3946
4167
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4168
+ const previousProperties = snapshotProperties();
3947
4169
  const previousRendered = rebuildRenderedWith(previousOptions);
3948
4170
  replaceWorkingPoints(nextPoints);
3949
4171
  if (nextAmplifierPlacements !== void 0) workingAmplifierPlacements = structuredClone(nextAmplifierPlacements);
@@ -3963,7 +4185,7 @@ function runEdit(opts) {
3963
4185
  recomputeDirty();
3964
4186
  dragInFlight = false;
3965
4187
  drainPendingModes();
3966
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "gesture", "Edit", preGestureBoxAngle);
4188
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, "gesture", "Edit", preGestureBoxAngle);
3967
4189
  }
3968
4190
  function setOptions(partial) {
3969
4191
  if (ctx.settled) return;
@@ -3973,12 +4195,13 @@ function runEdit(opts) {
3973
4195
  const previousNonGeometryDirty = nonGeometryDirty;
3974
4196
  const previousTextAmplifiers = workingTextAmplifiers;
3975
4197
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4198
+ const previousProperties = snapshotProperties();
3976
4199
  const previousRendered = rebuildRendered();
3977
4200
  replaceWorkingOptions({
3978
4201
  ...workingOptions ?? {},
3979
4202
  ...partial ?? {}
3980
4203
  });
3981
- if (JSON.stringify(workingOptions) === JSON.stringify(previousOptions)) return;
4204
+ if (jsonEqual(workingOptions, previousOptions)) return;
3982
4205
  let currentRendered;
3983
4206
  try {
3984
4207
  currentRendered = rebuildRendered();
@@ -3989,7 +4212,7 @@ function runEdit(opts) {
3989
4212
  renderWorkingGraphicAndHandles();
3990
4213
  nonGeometryDirty = true;
3991
4214
  dirty = true;
3992
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
4215
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, "programmatic", "Change options");
3993
4216
  }
3994
4217
  function setStyle(partial) {
3995
4218
  if (ctx.settled) return;
@@ -3999,12 +4222,13 @@ function runEdit(opts) {
3999
4222
  const previousNonGeometryDirty = nonGeometryDirty;
4000
4223
  const previousTextAmplifiers = workingTextAmplifiers;
4001
4224
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4225
+ const previousProperties = snapshotProperties();
4002
4226
  const previousRendered = rebuildRendered();
4003
4227
  currentStyle = {
4004
4228
  ...currentStyle ?? {},
4005
4229
  ...partial
4006
4230
  };
4007
- if (JSON.stringify(currentStyle) === JSON.stringify(previousStyle)) return;
4231
+ if (jsonEqual(currentStyle, previousStyle)) return;
4008
4232
  let currentRendered;
4009
4233
  try {
4010
4234
  currentRendered = rebuildRendered();
@@ -4015,7 +4239,7 @@ function runEdit(opts) {
4015
4239
  renderWorkingGraphicAndHandles();
4016
4240
  nonGeometryDirty = true;
4017
4241
  dirty = true;
4018
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
4242
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, "programmatic", "Change style");
4019
4243
  }
4020
4244
  /**
4021
4245
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -4040,6 +4264,7 @@ function runEdit(opts) {
4040
4264
  const previousNonGeometryDirty = nonGeometryDirty;
4041
4265
  const previousTextAmplifiers = workingTextAmplifiers;
4042
4266
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4267
+ const previousProperties = snapshotProperties();
4043
4268
  const previousRendered = rebuildRendered();
4044
4269
  const merged = { ...workingTextAmplifiers ?? {} };
4045
4270
  for (const [key, value] of Object.entries(partial)) {
@@ -4050,7 +4275,7 @@ function runEdit(opts) {
4050
4275
  if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
4051
4276
  }
4052
4277
  workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
4053
- if (JSON.stringify(workingTextAmplifiers) === JSON.stringify(previousTextAmplifiers)) return;
4278
+ if (jsonEqual(workingTextAmplifiers, previousTextAmplifiers)) return;
4054
4279
  let currentRendered;
4055
4280
  try {
4056
4281
  currentRendered = rebuildRendered();
@@ -4061,7 +4286,7 @@ function runEdit(opts) {
4061
4286
  renderWorkingGraphicAndHandles();
4062
4287
  nonGeometryDirty = true;
4063
4288
  dirty = true;
4064
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
4289
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, "programmatic", "Change text");
4065
4290
  }
4066
4291
  function setAmplifierPlacements(partial, source = "programmatic", historyLabel = "Move label") {
4067
4292
  if (ctx.settled) return;
@@ -4071,6 +4296,7 @@ function runEdit(opts) {
4071
4296
  const previousNonGeometryDirty = nonGeometryDirty;
4072
4297
  const previousTextAmplifiers = workingTextAmplifiers;
4073
4298
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4299
+ const previousProperties = snapshotProperties();
4074
4300
  const previousRendered = rebuildRendered();
4075
4301
  const next = { ...workingAmplifierPlacements ?? {} };
4076
4302
  for (const [field, value] of Object.entries(partial)) {
@@ -4083,12 +4309,12 @@ function runEdit(opts) {
4083
4309
  else next[field] = merged;
4084
4310
  }
4085
4311
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
4086
- if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
4312
+ if (jsonEqual(workingAmplifierPlacements, previousAmplifierPlacements)) return;
4087
4313
  const currentRendered = rebuildRendered();
4088
4314
  renderWorkingGraphicAndHandles();
4089
4315
  nonGeometryDirty = true;
4090
4316
  dirty = true;
4091
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
4317
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, source, historyLabel);
4092
4318
  }
4093
4319
  /**
4094
4320
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -4106,12 +4332,13 @@ function runEdit(opts) {
4106
4332
  const previousSizeAnchor = sizeAnchor;
4107
4333
  const previousTextAmplifiers = workingTextAmplifiers;
4108
4334
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4335
+ const previousProperties = snapshotProperties();
4109
4336
  const previousRendered = rebuildRendered();
4110
4337
  sizeAnchor = next;
4111
4338
  const currentRendered = rebuildRendered();
4112
4339
  nonGeometryDirty = true;
4113
4340
  dirty = true;
4114
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change size anchor");
4341
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, previousProperties, "programmatic", "Change size anchor");
4115
4342
  }
4116
4343
  function modesEqual(a, b) {
4117
4344
  if (a.length !== b.length) return false;
@@ -4138,34 +4365,38 @@ function runEdit(opts) {
4138
4365
  style: value.style ? { ...value.style } : void 0,
4139
4366
  textAmplifiers: value.textAmplifiers ? { ...value.textAmplifiers } : void 0,
4140
4367
  amplifierPlacements: value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0,
4368
+ properties: value.properties ? structuredClone(value.properties) : void 0,
4141
4369
  sizeAnchor: value.sizeAnchor,
4142
4370
  nonGeometryDirty: value.nonGeometryDirty,
4143
4371
  boxAngle: value.boxAngle
4144
4372
  }),
4145
- equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
4146
- isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
4373
+ equals: (a, b) => deepEqual(a, b),
4374
+ isCurrent: (value) => deepEqual(captureHistory(), value),
4147
4375
  apply(from, to, source) {
4148
4376
  const previousRendered = rebuildRendered();
4149
4377
  restoreHistory(to);
4150
4378
  renderWorkingGraphic();
4151
4379
  renderHandlesLayer();
4152
4380
  recomputeDirty();
4153
- emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
4154
- return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
4381
+ emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, from.properties, source, "Edit", from.boxAngle);
4382
+ return !ctx.settled && deepEqual(captureHistory(), to);
4155
4383
  }
4156
4384
  });
4157
4385
  const session = {
4158
4386
  history,
4159
4387
  graphic: measure,
4160
4388
  get workingGraphic() {
4161
- return cloneControlMeasure({
4389
+ const working = {
4162
4390
  ...measure,
4163
4391
  controlPoints: workingPoints.map((point) => [...point]),
4164
4392
  options: { ...workingOptions ?? {} },
4165
4393
  style: currentStyle ? { ...currentStyle } : void 0,
4166
4394
  textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
4167
4395
  amplifierPlacements: snapshotAmplifierPlacements()
4168
- });
4396
+ };
4397
+ if (workingProperties) working.properties = workingProperties;
4398
+ else delete working.properties;
4399
+ return cloneControlMeasure(working);
4169
4400
  },
4170
4401
  get controlPoints() {
4171
4402
  return workingPoints.map((p) => [...p]);
@@ -4212,21 +4443,23 @@ function runEdit(opts) {
4212
4443
  const previous = rebuildRendered();
4213
4444
  const previousNonGeometryDirty = nonGeometryDirty;
4214
4445
  const previousSizeAnchor = sizeAnchor;
4446
+ const previousProperties = snapshotProperties();
4215
4447
  const current = session.workingGraphic;
4216
4448
  const next = updater(current);
4217
4449
  if (next.id !== measure.id || next.kind !== measure.kind) throw new TypeError("updateGraphic must preserve control-measure id and kind");
4218
- if (JSON.stringify(next) === JSON.stringify(current)) return;
4450
+ if (deepEqual(next, current)) return;
4219
4451
  renderMeasureForAdapter(adapter, next, renderOpts);
4220
4452
  workingPoints.splice(0, workingPoints.length, ...next.controlPoints.map((point) => [...point]));
4221
4453
  workingOptions = { ...next.options ?? {} };
4222
4454
  currentStyle = next.style ? { ...next.style } : void 0;
4223
4455
  workingTextAmplifiers = next.textAmplifiers ? { ...next.textAmplifiers } : void 0;
4224
4456
  workingAmplifierPlacements = next.amplifierPlacements ? structuredClone(next.amplifierPlacements) : void 0;
4457
+ workingProperties = next.properties ? structuredClone(next.properties) : void 0;
4225
4458
  nonGeometryDirty = true;
4226
4459
  renderWorkingGraphic();
4227
4460
  renderHandlesLayer();
4228
4461
  recomputeDirty();
4229
- emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements, "programmatic", "Update graphic");
4462
+ emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements, previousProperties, "programmatic", "Update graphic");
4230
4463
  },
4231
4464
  setOptions(partial) {
4232
4465
  setOptions(partial);
@@ -4768,6 +5001,42 @@ function boundProperty(key, value) {
4768
5001
  return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
4769
5002
  }
4770
5003
  //#endregion
5004
+ //#region src/tactical-draw/frame-coalescer.ts
5005
+ /**
5006
+ * @param run Callback invoked with the latest scheduled args, at most once per frame.
5007
+ */
5008
+ function createFrameCoalescer(run) {
5009
+ const raf = globalThis.requestAnimationFrame?.bind(globalThis);
5010
+ const cancelRaf = globalThis.cancelAnimationFrame?.bind(globalThis);
5011
+ if (!raf || !cancelRaf) return {
5012
+ schedule(...args) {
5013
+ run(...args);
5014
+ },
5015
+ cancel() {}
5016
+ };
5017
+ let handle = null;
5018
+ let latestArgs = null;
5019
+ return {
5020
+ schedule(...args) {
5021
+ latestArgs = args;
5022
+ if (handle !== null) return;
5023
+ handle = raf(() => {
5024
+ handle = null;
5025
+ const pending = latestArgs;
5026
+ latestArgs = null;
5027
+ if (pending) run(...pending);
5028
+ });
5029
+ },
5030
+ cancel() {
5031
+ if (handle !== null) {
5032
+ cancelRaf(handle);
5033
+ handle = null;
5034
+ }
5035
+ latestArgs = null;
5036
+ }
5037
+ };
5038
+ }
5039
+ //#endregion
4771
5040
  //#region src/tactical-draw/transform-controller.ts
4772
5041
  /** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
4773
5042
  const MIN_POINT_SYMBOL_SIZE = .1;
@@ -5479,12 +5748,14 @@ function runGroupTransform(opts) {
5479
5748
  renderHandlesLayer();
5480
5749
  recomputeDirty();
5481
5750
  }
5751
+ const boxDragPaintCoalescer = createFrameCoalescer(paintBoxDragSnapshot);
5482
5752
  const onDriverMove = (pixel, info) => {
5483
5753
  if (ctx.settled) return;
5484
- if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
5754
+ if (activeBoxDrag) boxDragPaintCoalescer.schedule(pixel, info?.shiftKey);
5485
5755
  };
5486
5756
  const onDriverUp = (pixel, info) => {
5487
5757
  if (!activeBoxDrag) return;
5758
+ boxDragPaintCoalescer.cancel();
5488
5759
  if (ctx.settled) {
5489
5760
  activeBoxDrag = null;
5490
5761
  dragInFlight = false;
@@ -5545,6 +5816,7 @@ function runGroupTransform(opts) {
5545
5816
  if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
5546
5817
  },
5547
5818
  extraTeardown: () => {
5819
+ boxDragPaintCoalescer.cancel();
5548
5820
  if (activeBoxDrag !== null) {
5549
5821
  driver.setDragPanEnabled(true);
5550
5822
  activeBoxDrag = null;
@@ -5716,7 +5988,7 @@ function runPointSymbolEdit(opts) {
5716
5988
  }, {
5717
5989
  rollback() {
5718
5990
  working = clonePointSymbol(previousState.working);
5719
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
5991
+ dirty = !deepEqual(working, symbol);
5720
5992
  render();
5721
5993
  },
5722
5994
  onAccepted
@@ -5725,11 +5997,11 @@ function runPointSymbolEdit(opts) {
5725
5997
  const history = createSessionHistory({
5726
5998
  initial: captureHistory(),
5727
5999
  clone: cloneHistory,
5728
- equals: (a, b) => JSON.stringify(a.working) === JSON.stringify(b.working),
5729
- isCurrent: (value) => JSON.stringify(working) === JSON.stringify(value.working),
6000
+ equals: (a, b) => deepEqual(a.working, b.working),
6001
+ isCurrent: (value) => deepEqual(working, value.working),
5730
6002
  apply(from, to, source) {
5731
6003
  working = clonePointSymbol(to.working);
5732
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
6004
+ dirty = !deepEqual(working, symbol);
5733
6005
  render();
5734
6006
  return emitChange(from, source);
5735
6007
  }
@@ -5737,7 +6009,7 @@ function runPointSymbolEdit(opts) {
5737
6009
  const apply = (next, source = "programmatic", label = "Update graphic") => {
5738
6010
  const previousState = captureHistory();
5739
6011
  working = clonePointSymbol(next);
5740
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
6012
+ dirty = !deepEqual(working, symbol);
5741
6013
  render();
5742
6014
  emitChange(previousState, source, () => history.record(captureHistory(), label));
5743
6015
  };
@@ -5930,6 +6202,17 @@ var GraphicStackCoordinator = class {
5930
6202
  //#endregion
5931
6203
  //#region src/tactical-draw/tactical-draw.ts
5932
6204
  /**
6205
+ * Source of {@link FeatureGroup.revision} stamps. Module-scoped rather than a
6206
+ * per-instance counter: adapters keep per-group bundles on host-supplied layer
6207
+ * handles, and `destroy()` intentionally leaves those layers untouched so a
6208
+ * host can attach a fresh façade to the same adapter/layer for reuse. An
6209
+ * instance counter would restart at 1 on the new façade, so a bundle the
6210
+ * previous façade stored at revision 1 — with different content — would
6211
+ * compare equal and the adapter would wrongly skip the repaint. A counter
6212
+ * shared across every façade in the JS realm guarantees revisions never repeat.
6213
+ */
6214
+ let nextGraphicGroupRevision = 0;
6215
+ /**
5933
6216
  * `TacticalDraw` façade — slice #6 (issue #45).
5934
6217
  *
5935
6218
  * Provides the constructor + lifecycle + `render(measures)` reconcile path.
@@ -5998,6 +6281,23 @@ var TacticalDraw = class {
5998
6281
  lastRenderedMeasures = [];
5999
6282
  snapGeometryFeatures = [];
6000
6283
  /**
6284
+ * Rendered graphics-layer groups, keyed on Graphic snapshot identity. See
6285
+ * {@link renderGraphicGroup} for the invalidation contract; swapped for a
6286
+ * fresh map (not cleared entry-by-entry) so stale entries are collectable.
6287
+ */
6288
+ graphicGroupCache = /* @__PURE__ */ new WeakMap();
6289
+ /**
6290
+ * Last {@link PointSymbolCapability.generation} observed from `pointSymbols`,
6291
+ * initialised from the capability at construction. Compared at the top of
6292
+ * every {@link reconcileGraphicsStack} so a host calling `invalidate()` after
6293
+ * changing renderer-global config (e.g. milsymbol options) drops the render
6294
+ * cache even though the Graphic snapshots handed to `render()` are
6295
+ * unchanged — otherwise the cache would keep serving pre-invalidation
6296
+ * output and the capability would never be re-consulted. `undefined` when
6297
+ * there is no capability, or the capability doesn't expose `generation`.
6298
+ */
6299
+ lastSeenPointSymbolGeneration;
6300
+ /**
6001
6301
  * Ids of graphics currently carrying a passive selection-highlight outline
6002
6302
  * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
6003
6303
  * re-projected on every view change and re-evaluated at the end of every
@@ -6017,6 +6317,7 @@ var TacticalDraw = class {
6017
6317
  this.defaultInteractionStyle = options?.interactionStyle;
6018
6318
  this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
6019
6319
  this.pointSymbols = options?.pointSymbols;
6320
+ this.lastSeenPointSymbolGeneration = this.pointSymbols?.generation;
6020
6321
  this.snappingOptions = options?.snapping;
6021
6322
  this.resolveSnap = createSnapResolver(adapter, () => this.lastRenderedMeasures, () => this.snapGeometryFeatures, () => this.snappingOptions);
6022
6323
  const owned = /* @__PURE__ */ new Set();
@@ -6044,27 +6345,68 @@ var TacticalDraw = class {
6044
6345
  clearSnapResult() {
6045
6346
  this.snappingOptions?.onSnap?.(null);
6046
6347
  }
6047
- /** Render one atomic Graphic group for the engine-neutral graphics stack. */
6348
+ /**
6349
+ * Render one atomic Graphic group for the engine-neutral graphics stack,
6350
+ * reusing the previous render when nothing that feeds it has changed.
6351
+ *
6352
+ * The cache is keyed on the Graphic snapshot's object identity: hosts hand
6353
+ * `render()` a fresh object whenever a graphic's data changes, so a changed
6354
+ * graphic misses by construction. (A host that mutates a Graphic in place and
6355
+ * re-renders the same reference is outside the contract — `render()` has
6356
+ * always been documented as taking snapshots.) Rendered output also depends
6357
+ * on live view state and on the resolved graphics style; the style is fixed
6358
+ * at construction, and every view change drops the whole cache. Crucially,
6359
+ * the view cannot change during a box drag (drag-pan is disabled), so every
6360
+ * graphic outside the selection stays a cache hit for the whole gesture and
6361
+ * per-frame reconcile cost becomes O(selection) rather than O(document).
6362
+ */
6048
6363
  renderGraphicGroup(graphic) {
6049
- const renderOpts = { graphicsStyle: this.graphicsStyle };
6364
+ const cached = this.graphicGroupCache.get(graphic);
6365
+ if (cached) return cached;
6366
+ const group = this.buildGraphicGroup(graphic);
6367
+ this.graphicGroupCache.set(graphic, group);
6368
+ return group;
6369
+ }
6370
+ /**
6371
+ * Render a Graphic group from scratch and stamp it with a fresh revision,
6372
+ * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
6373
+ * its doc comment for why it isn't a per-instance field).
6374
+ */
6375
+ buildGraphicGroup(graphic) {
6376
+ const revision = ++nextGraphicGroupRevision;
6050
6377
  if (isPointSymbol(graphic)) {
6051
6378
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
6052
6379
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
6053
6380
  return {
6054
6381
  id: graphic.id,
6055
6382
  features: [plan.feature],
6056
- iconResources: [plan.resource]
6383
+ iconResources: [plan.resource],
6384
+ revision
6057
6385
  };
6058
6386
  }
6059
- const render = renderMeasureForAdapter(this.adapter, graphic, renderOpts);
6387
+ const render = renderMeasureForAdapter(this.adapter, graphic, { graphicsStyle: this.graphicsStyle });
6060
6388
  return {
6061
6389
  id: graphic.id,
6062
6390
  features: render.features,
6063
- iconResources: []
6391
+ iconResources: [],
6392
+ revision
6064
6393
  };
6065
6394
  }
6395
+ /**
6396
+ * Drop every cached render. Called whenever an input shared by all graphic
6397
+ * renders changes — the live view state, or (via {@link reconcileGraphicsStack})
6398
+ * the point-symbol capability's `generation`.
6399
+ */
6400
+ invalidateGraphicGroupCache() {
6401
+ this.graphicGroupCache = /* @__PURE__ */ new WeakMap();
6402
+ }
6066
6403
  /** Submit one complete grouped desired state for the host stack plus overrides. */
6067
6404
  reconcileGraphicsStack(graphics = this.lastRenderedMeasures) {
6405
+ const currentGeneration = this.pointSymbols?.generation;
6406
+ if (currentGeneration !== this.lastSeenPointSymbolGeneration) {
6407
+ this.invalidateGraphicGroupCache();
6408
+ this.lastSeenPointSymbolGeneration = currentGeneration;
6409
+ }
6068
6410
  const groups = this.graphicStack.desiredGroups(graphics, (graphic) => this.renderGraphicGroup(graphic));
6069
6411
  this.adapter.setLayerFeatureGroups(this.graphicsLayer, groups);
6070
6412
  this.snapGeometryFeatures = groups.flatMap((group) => group.features);
@@ -6093,6 +6435,7 @@ var TacticalDraw = class {
6093
6435
  */
6094
6436
  handleViewChange = () => {
6095
6437
  if (this.destroyed || !this.hasRendered) return;
6438
+ this.invalidateGraphicGroupCache();
6096
6439
  this.renderHighlights();
6097
6440
  const excluded = this.graphicsStackExcludedIds();
6098
6441
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
@@ -7055,15 +7398,25 @@ function resolveSlot(adapter, supplied, owned) {
7055
7398
  * expressions without re-spelling the pattern vocabulary.
7056
7399
  */
7057
7400
  const HATCH_PATTERNS = [
7058
- "hachure",
7059
- "backhachure",
7060
- "cross"
7401
+ "hatch",
7402
+ "reverse-hatch",
7403
+ "cross-hatch",
7404
+ "horizontal",
7405
+ "vertical",
7406
+ "dots"
7061
7407
  ];
7062
7408
  /** Tile edge length, CSS px, before `pixelRatio` scaling. Kept a power of two. */
7063
7409
  const BASE_TILE_SIZE = 32;
7064
7410
  /** Line spacing as a fraction of the tile edge — 4 repeats per tile. */
7065
7411
  const SPACING_DIVISOR = 4;
7066
7412
  /**
7413
+ * Dot radius, CSS px, before `pixelRatio` scaling — the `"dots"` counterpart to
7414
+ * the 1px line width, sized so a dot reads at the same weight as a hatch line.
7415
+ * Must stay in lockstep with the vector tiles in the Leaflet adapter and the
7416
+ * static previews.
7417
+ */
7418
+ const DOT_RADIUS = 1.25;
7419
+ /**
7067
7420
  * Stable id for a rasterized tile, keyed by pattern and color. Adapters use
7068
7421
  * this as the image-registry key (MapLibre `addImage` id, OpenLayers pattern
7069
7422
  * cache key) so the same color+pattern never gets rasterized twice. Color is
@@ -7143,8 +7496,8 @@ const SQRT2 = Math.SQRT2;
7143
7496
  /**
7144
7497
  * Coverage in `[0, 1]` of one diagonal line family (slope +1 or -1, both
7145
7498
  * period `spacing`) at pixel center `(x, y)`, antialiased over a ~1 device-px
7146
- * band. `axis` selects `y - x` (the "/" hachure) or `y + x` (the "\" mirror,
7147
- * "backhachure", which "cross" unions on top of the hachure). Because `spacing`
7499
+ * band. `axis` selects `y - x` (the "/" hatch) or `y + x` (the "\" mirror,
7500
+ * "reverse-hatch", which "cross-hatch" unions on top of the hatch). Because `spacing`
7148
7501
  * divides the tile dimension evenly, shifting
7149
7502
  * `x` or `y` by a whole tile is a whole number of `spacing` periods, so the
7150
7503
  * mod-distance — and therefore the coverage — is identical at the tile edges
@@ -7152,30 +7505,61 @@ const SQRT2 = Math.SQRT2;
7152
7505
  * seamless.
7153
7506
  */
7154
7507
  function lineCoverage(x, y, spacing, lineWidth, axis) {
7155
- const t = axis === "/" ? y - x : y + x;
7156
- const perpDist = Math.abs(centeredMod(t, spacing)) / SQRT2;
7508
+ const t = axis === "/" ? y - x : axis === "\\" ? y + x : (axis === "-" ? y : x) - spacing / 2;
7509
+ const raw = Math.abs(centeredMod(t, spacing));
7510
+ const perpDist = axis === "/" || axis === "\\" ? raw / SQRT2 : raw;
7157
7511
  const halfWidth = lineWidth / 2;
7158
7512
  return Math.min(1, Math.max(0, halfWidth + .5 - perpDist));
7159
7513
  }
7160
7514
  /**
7515
+ * Coverage in `[0, 1]` of a square lattice of dots — one per `spacing × spacing`
7516
+ * cell, centered in the cell so the tile stays seamless — antialiased over the
7517
+ * same ~1px band as {@link lineCoverage}.
7518
+ */
7519
+ function dotCoverage(x, y, spacing, radius) {
7520
+ const dx = centeredMod(x - spacing / 2, spacing);
7521
+ const dy = centeredMod(y - spacing / 2, spacing);
7522
+ return Math.min(1, Math.max(0, radius + .5 - Math.hypot(dx, dy)));
7523
+ }
7524
+ /**
7161
7525
  * Rasterize one seamless hatch tile as a straight RGBA pixel buffer — no
7162
7526
  * canvas involved, so this runs identically in a browser adapter and in
7163
7527
  * jsdom/node tests. Diagonal lines are snapped to slope ±1 (45°) so the
7164
- * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hachure"`
7165
- * draws the "/" diagonal, `"backhachure"` its "\" mirror, and `"cross"` unions
7166
- * both via `Math.max` coverage.
7528
+ * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hatch"`
7529
+ * draws the "/" diagonal, `"reverse-hatch"` its "\" mirror, and `"cross-hatch"` unions
7530
+ * both via `Math.max` coverage. `"horizontal"`/`"vertical"` draw the
7531
+ * axis-aligned families at the same spacing, and `"dots"` a square lattice of
7532
+ * dots (see {@link dotCoverage}).
7167
7533
  */
7168
7534
  function makeFillPatternImage(color, pattern, pixelRatio = 1) {
7169
7535
  const size = fillPatternImageDimension(pixelRatio);
7170
7536
  const spacing = size / SPACING_DIVISOR;
7171
7537
  const lineWidth = Math.max(1, Math.round(pixelRatio));
7538
+ const dotRadius = DOT_RADIUS * Math.max(1, Math.round(pixelRatio));
7172
7539
  const { r, g, b, a } = parseColor(color);
7173
7540
  const data = new Uint8ClampedArray(size * size * 4);
7174
7541
  for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
7175
7542
  const cx = x + .5;
7176
7543
  const cy = y + .5;
7177
- let coverage = pattern === "backhachure" ? lineCoverage(cx, cy, spacing, lineWidth, "\\") : lineCoverage(cx, cy, spacing, lineWidth, "/");
7178
- if (pattern === "cross") coverage = Math.max(coverage, lineCoverage(cx, cy, spacing, lineWidth, "\\"));
7544
+ let coverage;
7545
+ switch (pattern) {
7546
+ case "reverse-hatch":
7547
+ coverage = lineCoverage(cx, cy, spacing, lineWidth, "\\");
7548
+ break;
7549
+ case "cross-hatch":
7550
+ coverage = Math.max(lineCoverage(cx, cy, spacing, lineWidth, "/"), lineCoverage(cx, cy, spacing, lineWidth, "\\"));
7551
+ break;
7552
+ case "horizontal":
7553
+ coverage = lineCoverage(cx, cy, spacing, lineWidth, "-");
7554
+ break;
7555
+ case "vertical":
7556
+ coverage = lineCoverage(cx, cy, spacing, lineWidth, "|");
7557
+ break;
7558
+ case "dots":
7559
+ coverage = dotCoverage(cx, cy, spacing, dotRadius);
7560
+ break;
7561
+ default: coverage = lineCoverage(cx, cy, spacing, lineWidth, "/");
7562
+ }
7179
7563
  const i = (y * size + x) * 4;
7180
7564
  data[i] = r;
7181
7565
  data[i + 1] = g;
@@ -45,7 +45,7 @@ interface EditPointerDriver {
45
45
  * Shared hatch fill-pattern tiles for area control-measure fills (ADR-0035
46
46
  * `FillPattern`, excluding `"solid"` which every adapter already renders as a
47
47
  * flat color). Both the MapLibre adapter (raster `addImage` pattern) and the
48
- * OpenLayers adapter (canvas `Pattern` fill) need the *same* diagonal-hachure
48
+ * OpenLayers adapter (canvas `Pattern` fill) need the *same* diagonal-hatch
49
49
  * texture, so it is rasterized once here in pure math — no `HTMLCanvasElement`
50
50
  * — so it works in adapter code and in jsdom/node tests alike.
51
51
  */
@@ -76,9 +76,11 @@ declare function fillPatternImageDimension(pixelRatio: number): number;
76
76
  * Rasterize one seamless hatch tile as a straight RGBA pixel buffer — no
77
77
  * canvas involved, so this runs identically in a browser adapter and in
78
78
  * jsdom/node tests. Diagonal lines are snapped to slope ±1 (45°) so the
79
- * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hachure"`
80
- * draws the "/" diagonal, `"backhachure"` its "\" mirror, and `"cross"` unions
81
- * both via `Math.max` coverage.
79
+ * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hatch"`
80
+ * draws the "/" diagonal, `"reverse-hatch"` its "\" mirror, and `"cross-hatch"` unions
81
+ * both via `Math.max` coverage. `"horizontal"`/`"vertical"` draw the
82
+ * axis-aligned families at the same spacing, and `"dots"` a square lattice of
83
+ * dots (see {@link dotCoverage}).
82
84
  */
83
85
  declare function makeFillPatternImage(color: string, pattern: HatchPattern, pixelRatio?: number): {
84
86
  width: number;
@@ -150,7 +152,7 @@ interface HandleStyle {
150
152
  fillColor?: string;
151
153
  /**
152
154
  * How `fillColor` paints a filled part's interior. Absent or `"solid"` is
153
- * today's flat fill; `"hachure"`/`"backhachure"`/`"cross"` request the shared hatch texture
155
+ * today's flat fill; `"hatch"`/`"reverse-hatch"`/`"cross-hatch"`/`"horizontal"`/`"vertical"`/`"dots"` request the shared pattern texture
154
156
  * ({@link makeFillPatternImage}). Alpha rides in `fillColor` — there is no
155
157
  * separate opacity field. Adapters that cannot rasterize a pattern degrade
156
158
  * to solid. Per-feature only: layer-level `StyleOptions` has no equivalent.
@@ -188,6 +190,15 @@ interface FeatureGroup {
188
190
  id: string;
189
191
  features: readonly Feature[];
190
192
  iconResources: readonly IconResource[];
193
+ /**
194
+ * Monotonic stamp identifying this group's rendered content, when the
195
+ * producer supplies one. Two groups with the same `id` and the same
196
+ * `revision` carry byte-identical `features` and `iconResources`, so an
197
+ * adapter may skip any diff or upload work for them. A missing `revision`
198
+ * means "unknown" — adapters must fall back to their own comparison and
199
+ * must never treat two absent stamps as equal.
200
+ */
201
+ revision?: number;
191
202
  }
192
203
  /**
193
204
  * Click-style hit event delivered by {@link MapAdapter.onPick}.
@@ -1,4 +1,4 @@
1
- import { a as MapAdapter, f as PixelCoordinate, i as LayerId } from "./map-adapter-meRo5VY2.mjs";
1
+ import { a as MapAdapter, f as PixelCoordinate, i as LayerId } from "./map-adapter-84irgVJR.mjs";
2
2
  import { Feature } from "geojson";
3
3
 
4
4
  //#region src/testing/feature-group-conformance.d.ts
package/dist/testing.mjs CHANGED
@@ -195,7 +195,7 @@ function runGroupedPickConformance(name, createSubject) {
195
195
  "patterned",
196
196
  {
197
197
  fillColor: "#ff0000",
198
- fillPattern: "hachure"
198
+ fillPattern: "hatch"
199
199
  },
200
200
  "lower"
201
201
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.37",
3
+ "version": "0.2.0-alpha.39",
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.2.0-alpha.26"
42
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.28"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "vitest": ">=4.0.0"