@orbat-mapper/tactical-draw 0.2.0-alpha.38 → 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-BIlrWk6y.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
 
@@ -625,7 +625,15 @@ interface EditSession {
625
625
  readonly history: SessionHistory;
626
626
  /** Immutable edit-start input. */
627
627
  readonly graphic: ControlMeasure;
628
- /** 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
+ */
629
637
  readonly workingGraphic: ControlMeasure;
630
638
  /** Live working state. Copy on read. */
631
639
  readonly controlPoints: readonly Position[];
package/dist/index.mjs CHANGED
@@ -2924,6 +2924,200 @@ function beginBoxDrag(args) {
2924
2924
  };
2925
2925
  }
2926
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
2927
3121
  //#region src/tactical-draw/session-history.ts
2928
3122
  /** @internal Controller-owned history stack. */
2929
3123
  function createSessionHistory(options) {
@@ -3612,9 +3806,28 @@ function runEdit(opts) {
3612
3806
  let workingOptions = { ...measure.options ?? {} };
3613
3807
  let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
3614
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;
3615
3810
  function snapshotAmplifierPlacements() {
3616
3811
  return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
3617
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
+ }
3618
3831
  let modes = requestedModes;
3619
3832
  let dirty = false;
3620
3833
  let nonGeometryDirty = false;
@@ -3685,6 +3898,7 @@ function runEdit(opts) {
3685
3898
  style: currentStyle ? { ...currentStyle } : void 0,
3686
3899
  textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
3687
3900
  amplifierPlacements: snapshotAmplifierPlacements(),
3901
+ properties: snapshotProperties(),
3688
3902
  sizeAnchor,
3689
3903
  nonGeometryDirty,
3690
3904
  boxAngle
@@ -3696,6 +3910,7 @@ function runEdit(opts) {
3696
3910
  currentStyle = value.style ? { ...value.style } : void 0;
3697
3911
  workingTextAmplifiers = value.textAmplifiers ? { ...value.textAmplifiers } : void 0;
3698
3912
  workingAmplifierPlacements = value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0;
3913
+ workingProperties = value.properties ? structuredClone(value.properties) : void 0;
3699
3914
  sizeAnchor = value.sizeAnchor;
3700
3915
  nonGeometryDirty = value.nonGeometryDirty;
3701
3916
  boxAngle = value.boxAngle;
@@ -3782,6 +3997,8 @@ function runEdit(opts) {
3782
3997
  else delete working.textAmplifiers;
3783
3998
  if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
3784
3999
  else delete working.amplifierPlacements;
4000
+ if (workingProperties) working.properties = workingProperties;
4001
+ else delete working.properties;
3785
4002
  let features;
3786
4003
  try {
3787
4004
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -3859,6 +4076,8 @@ function runEdit(opts) {
3859
4076
  else delete working.textAmplifiers;
3860
4077
  if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
3861
4078
  else delete working.amplifierPlacements;
4079
+ if (workingProperties) working.properties = workingProperties;
4080
+ else delete working.properties;
3862
4081
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
3863
4082
  return {
3864
4083
  graphic: cloneControlMeasure(committed),
@@ -3904,7 +4123,7 @@ function runEdit(opts) {
3904
4123
  * the history record. The deferred reject/close/abort machinery, exception
3905
4124
  * collection, and record-before-rethrow ordering live in the emitter.
3906
4125
  */
3907
- 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) {
3908
4127
  changeEmitter.emit({
3909
4128
  source,
3910
4129
  graphic: currentRendered,
@@ -3918,6 +4137,7 @@ function runEdit(opts) {
3918
4137
  sizeAnchor = previousSizeAnchor;
3919
4138
  workingTextAmplifiers = previousTextAmplifiers;
3920
4139
  workingAmplifierPlacements = previousAmplifierPlacements;
4140
+ workingProperties = previousProperties;
3921
4141
  boxAngle = previousBoxAngle;
3922
4142
  nonGeometryDirty = previousNonGeometryDirty;
3923
4143
  renderWorkingGraphic();
@@ -3945,6 +4165,7 @@ function runEdit(opts) {
3945
4165
  const previousNonGeometryDirty = nonGeometryDirty;
3946
4166
  const previousTextAmplifiers = workingTextAmplifiers;
3947
4167
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4168
+ const previousProperties = snapshotProperties();
3948
4169
  const previousRendered = rebuildRenderedWith(previousOptions);
3949
4170
  replaceWorkingPoints(nextPoints);
3950
4171
  if (nextAmplifierPlacements !== void 0) workingAmplifierPlacements = structuredClone(nextAmplifierPlacements);
@@ -3964,7 +4185,7 @@ function runEdit(opts) {
3964
4185
  recomputeDirty();
3965
4186
  dragInFlight = false;
3966
4187
  drainPendingModes();
3967
- 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);
3968
4189
  }
3969
4190
  function setOptions(partial) {
3970
4191
  if (ctx.settled) return;
@@ -3974,12 +4195,13 @@ function runEdit(opts) {
3974
4195
  const previousNonGeometryDirty = nonGeometryDirty;
3975
4196
  const previousTextAmplifiers = workingTextAmplifiers;
3976
4197
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4198
+ const previousProperties = snapshotProperties();
3977
4199
  const previousRendered = rebuildRendered();
3978
4200
  replaceWorkingOptions({
3979
4201
  ...workingOptions ?? {},
3980
4202
  ...partial ?? {}
3981
4203
  });
3982
- if (JSON.stringify(workingOptions) === JSON.stringify(previousOptions)) return;
4204
+ if (jsonEqual(workingOptions, previousOptions)) return;
3983
4205
  let currentRendered;
3984
4206
  try {
3985
4207
  currentRendered = rebuildRendered();
@@ -3990,7 +4212,7 @@ function runEdit(opts) {
3990
4212
  renderWorkingGraphicAndHandles();
3991
4213
  nonGeometryDirty = true;
3992
4214
  dirty = true;
3993
- 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");
3994
4216
  }
3995
4217
  function setStyle(partial) {
3996
4218
  if (ctx.settled) return;
@@ -4000,12 +4222,13 @@ function runEdit(opts) {
4000
4222
  const previousNonGeometryDirty = nonGeometryDirty;
4001
4223
  const previousTextAmplifiers = workingTextAmplifiers;
4002
4224
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4225
+ const previousProperties = snapshotProperties();
4003
4226
  const previousRendered = rebuildRendered();
4004
4227
  currentStyle = {
4005
4228
  ...currentStyle ?? {},
4006
4229
  ...partial
4007
4230
  };
4008
- if (JSON.stringify(currentStyle) === JSON.stringify(previousStyle)) return;
4231
+ if (jsonEqual(currentStyle, previousStyle)) return;
4009
4232
  let currentRendered;
4010
4233
  try {
4011
4234
  currentRendered = rebuildRendered();
@@ -4016,7 +4239,7 @@ function runEdit(opts) {
4016
4239
  renderWorkingGraphicAndHandles();
4017
4240
  nonGeometryDirty = true;
4018
4241
  dirty = true;
4019
- 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");
4020
4243
  }
4021
4244
  /**
4022
4245
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -4041,6 +4264,7 @@ function runEdit(opts) {
4041
4264
  const previousNonGeometryDirty = nonGeometryDirty;
4042
4265
  const previousTextAmplifiers = workingTextAmplifiers;
4043
4266
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4267
+ const previousProperties = snapshotProperties();
4044
4268
  const previousRendered = rebuildRendered();
4045
4269
  const merged = { ...workingTextAmplifiers ?? {} };
4046
4270
  for (const [key, value] of Object.entries(partial)) {
@@ -4051,7 +4275,7 @@ function runEdit(opts) {
4051
4275
  if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
4052
4276
  }
4053
4277
  workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
4054
- if (JSON.stringify(workingTextAmplifiers) === JSON.stringify(previousTextAmplifiers)) return;
4278
+ if (jsonEqual(workingTextAmplifiers, previousTextAmplifiers)) return;
4055
4279
  let currentRendered;
4056
4280
  try {
4057
4281
  currentRendered = rebuildRendered();
@@ -4062,7 +4286,7 @@ function runEdit(opts) {
4062
4286
  renderWorkingGraphicAndHandles();
4063
4287
  nonGeometryDirty = true;
4064
4288
  dirty = true;
4065
- 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");
4066
4290
  }
4067
4291
  function setAmplifierPlacements(partial, source = "programmatic", historyLabel = "Move label") {
4068
4292
  if (ctx.settled) return;
@@ -4072,6 +4296,7 @@ function runEdit(opts) {
4072
4296
  const previousNonGeometryDirty = nonGeometryDirty;
4073
4297
  const previousTextAmplifiers = workingTextAmplifiers;
4074
4298
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4299
+ const previousProperties = snapshotProperties();
4075
4300
  const previousRendered = rebuildRendered();
4076
4301
  const next = { ...workingAmplifierPlacements ?? {} };
4077
4302
  for (const [field, value] of Object.entries(partial)) {
@@ -4084,12 +4309,12 @@ function runEdit(opts) {
4084
4309
  else next[field] = merged;
4085
4310
  }
4086
4311
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
4087
- if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
4312
+ if (jsonEqual(workingAmplifierPlacements, previousAmplifierPlacements)) return;
4088
4313
  const currentRendered = rebuildRendered();
4089
4314
  renderWorkingGraphicAndHandles();
4090
4315
  nonGeometryDirty = true;
4091
4316
  dirty = true;
4092
- 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);
4093
4318
  }
4094
4319
  /**
4095
4320
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -4107,12 +4332,13 @@ function runEdit(opts) {
4107
4332
  const previousSizeAnchor = sizeAnchor;
4108
4333
  const previousTextAmplifiers = workingTextAmplifiers;
4109
4334
  const previousAmplifierPlacements = snapshotAmplifierPlacements();
4335
+ const previousProperties = snapshotProperties();
4110
4336
  const previousRendered = rebuildRendered();
4111
4337
  sizeAnchor = next;
4112
4338
  const currentRendered = rebuildRendered();
4113
4339
  nonGeometryDirty = true;
4114
4340
  dirty = true;
4115
- 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");
4116
4342
  }
4117
4343
  function modesEqual(a, b) {
4118
4344
  if (a.length !== b.length) return false;
@@ -4139,34 +4365,38 @@ function runEdit(opts) {
4139
4365
  style: value.style ? { ...value.style } : void 0,
4140
4366
  textAmplifiers: value.textAmplifiers ? { ...value.textAmplifiers } : void 0,
4141
4367
  amplifierPlacements: value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0,
4368
+ properties: value.properties ? structuredClone(value.properties) : void 0,
4142
4369
  sizeAnchor: value.sizeAnchor,
4143
4370
  nonGeometryDirty: value.nonGeometryDirty,
4144
4371
  boxAngle: value.boxAngle
4145
4372
  }),
4146
- equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
4147
- isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
4373
+ equals: (a, b) => deepEqual(a, b),
4374
+ isCurrent: (value) => deepEqual(captureHistory(), value),
4148
4375
  apply(from, to, source) {
4149
4376
  const previousRendered = rebuildRendered();
4150
4377
  restoreHistory(to);
4151
4378
  renderWorkingGraphic();
4152
4379
  renderHandlesLayer();
4153
4380
  recomputeDirty();
4154
- emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
4155
- 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);
4156
4383
  }
4157
4384
  });
4158
4385
  const session = {
4159
4386
  history,
4160
4387
  graphic: measure,
4161
4388
  get workingGraphic() {
4162
- return cloneControlMeasure({
4389
+ const working = {
4163
4390
  ...measure,
4164
4391
  controlPoints: workingPoints.map((point) => [...point]),
4165
4392
  options: { ...workingOptions ?? {} },
4166
4393
  style: currentStyle ? { ...currentStyle } : void 0,
4167
4394
  textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
4168
4395
  amplifierPlacements: snapshotAmplifierPlacements()
4169
- });
4396
+ };
4397
+ if (workingProperties) working.properties = workingProperties;
4398
+ else delete working.properties;
4399
+ return cloneControlMeasure(working);
4170
4400
  },
4171
4401
  get controlPoints() {
4172
4402
  return workingPoints.map((p) => [...p]);
@@ -4213,21 +4443,23 @@ function runEdit(opts) {
4213
4443
  const previous = rebuildRendered();
4214
4444
  const previousNonGeometryDirty = nonGeometryDirty;
4215
4445
  const previousSizeAnchor = sizeAnchor;
4446
+ const previousProperties = snapshotProperties();
4216
4447
  const current = session.workingGraphic;
4217
4448
  const next = updater(current);
4218
4449
  if (next.id !== measure.id || next.kind !== measure.kind) throw new TypeError("updateGraphic must preserve control-measure id and kind");
4219
- if (JSON.stringify(next) === JSON.stringify(current)) return;
4450
+ if (deepEqual(next, current)) return;
4220
4451
  renderMeasureForAdapter(adapter, next, renderOpts);
4221
4452
  workingPoints.splice(0, workingPoints.length, ...next.controlPoints.map((point) => [...point]));
4222
4453
  workingOptions = { ...next.options ?? {} };
4223
4454
  currentStyle = next.style ? { ...next.style } : void 0;
4224
4455
  workingTextAmplifiers = next.textAmplifiers ? { ...next.textAmplifiers } : void 0;
4225
4456
  workingAmplifierPlacements = next.amplifierPlacements ? structuredClone(next.amplifierPlacements) : void 0;
4457
+ workingProperties = next.properties ? structuredClone(next.properties) : void 0;
4226
4458
  nonGeometryDirty = true;
4227
4459
  renderWorkingGraphic();
4228
4460
  renderHandlesLayer();
4229
4461
  recomputeDirty();
4230
- 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");
4231
4463
  },
4232
4464
  setOptions(partial) {
4233
4465
  setOptions(partial);
@@ -5756,7 +5988,7 @@ function runPointSymbolEdit(opts) {
5756
5988
  }, {
5757
5989
  rollback() {
5758
5990
  working = clonePointSymbol(previousState.working);
5759
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
5991
+ dirty = !deepEqual(working, symbol);
5760
5992
  render();
5761
5993
  },
5762
5994
  onAccepted
@@ -5765,11 +5997,11 @@ function runPointSymbolEdit(opts) {
5765
5997
  const history = createSessionHistory({
5766
5998
  initial: captureHistory(),
5767
5999
  clone: cloneHistory,
5768
- equals: (a, b) => JSON.stringify(a.working) === JSON.stringify(b.working),
5769
- 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),
5770
6002
  apply(from, to, source) {
5771
6003
  working = clonePointSymbol(to.working);
5772
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
6004
+ dirty = !deepEqual(working, symbol);
5773
6005
  render();
5774
6006
  return emitChange(from, source);
5775
6007
  }
@@ -5777,7 +6009,7 @@ function runPointSymbolEdit(opts) {
5777
6009
  const apply = (next, source = "programmatic", label = "Update graphic") => {
5778
6010
  const previousState = captureHistory();
5779
6011
  working = clonePointSymbol(next);
5780
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
6012
+ dirty = !deepEqual(working, symbol);
5781
6013
  render();
5782
6014
  emitChange(previousState, source, () => history.record(captureHistory(), label));
5783
6015
  };
@@ -7166,15 +7398,25 @@ function resolveSlot(adapter, supplied, owned) {
7166
7398
  * expressions without re-spelling the pattern vocabulary.
7167
7399
  */
7168
7400
  const HATCH_PATTERNS = [
7169
- "hachure",
7170
- "backhachure",
7171
- "cross"
7401
+ "hatch",
7402
+ "reverse-hatch",
7403
+ "cross-hatch",
7404
+ "horizontal",
7405
+ "vertical",
7406
+ "dots"
7172
7407
  ];
7173
7408
  /** Tile edge length, CSS px, before `pixelRatio` scaling. Kept a power of two. */
7174
7409
  const BASE_TILE_SIZE = 32;
7175
7410
  /** Line spacing as a fraction of the tile edge — 4 repeats per tile. */
7176
7411
  const SPACING_DIVISOR = 4;
7177
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
+ /**
7178
7420
  * Stable id for a rasterized tile, keyed by pattern and color. Adapters use
7179
7421
  * this as the image-registry key (MapLibre `addImage` id, OpenLayers pattern
7180
7422
  * cache key) so the same color+pattern never gets rasterized twice. Color is
@@ -7254,8 +7496,8 @@ const SQRT2 = Math.SQRT2;
7254
7496
  /**
7255
7497
  * Coverage in `[0, 1]` of one diagonal line family (slope +1 or -1, both
7256
7498
  * period `spacing`) at pixel center `(x, y)`, antialiased over a ~1 device-px
7257
- * band. `axis` selects `y - x` (the "/" hachure) or `y + x` (the "\" mirror,
7258
- * "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`
7259
7501
  * divides the tile dimension evenly, shifting
7260
7502
  * `x` or `y` by a whole tile is a whole number of `spacing` periods, so the
7261
7503
  * mod-distance — and therefore the coverage — is identical at the tile edges
@@ -7263,30 +7505,61 @@ const SQRT2 = Math.SQRT2;
7263
7505
  * seamless.
7264
7506
  */
7265
7507
  function lineCoverage(x, y, spacing, lineWidth, axis) {
7266
- const t = axis === "/" ? y - x : y + x;
7267
- 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;
7268
7511
  const halfWidth = lineWidth / 2;
7269
7512
  return Math.min(1, Math.max(0, halfWidth + .5 - perpDist));
7270
7513
  }
7271
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
+ /**
7272
7525
  * Rasterize one seamless hatch tile as a straight RGBA pixel buffer — no
7273
7526
  * canvas involved, so this runs identically in a browser adapter and in
7274
7527
  * jsdom/node tests. Diagonal lines are snapped to slope ±1 (45°) so the
7275
- * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hachure"`
7276
- * draws the "/" diagonal, `"backhachure"` its "\" mirror, and `"cross"` unions
7277
- * 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}).
7278
7533
  */
7279
7534
  function makeFillPatternImage(color, pattern, pixelRatio = 1) {
7280
7535
  const size = fillPatternImageDimension(pixelRatio);
7281
7536
  const spacing = size / SPACING_DIVISOR;
7282
7537
  const lineWidth = Math.max(1, Math.round(pixelRatio));
7538
+ const dotRadius = DOT_RADIUS * Math.max(1, Math.round(pixelRatio));
7283
7539
  const { r, g, b, a } = parseColor(color);
7284
7540
  const data = new Uint8ClampedArray(size * size * 4);
7285
7541
  for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
7286
7542
  const cx = x + .5;
7287
7543
  const cy = y + .5;
7288
- let coverage = pattern === "backhachure" ? lineCoverage(cx, cy, spacing, lineWidth, "\\") : lineCoverage(cx, cy, spacing, lineWidth, "/");
7289
- 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
+ }
7290
7563
  const i = (y * size + x) * 4;
7291
7564
  data[i] = r;
7292
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.
@@ -1,4 +1,4 @@
1
- import { a as MapAdapter, f as PixelCoordinate, i as LayerId } from "./map-adapter-BIlrWk6y.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.38",
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"