@orbat-mapper/tactical-draw 0.2.0-alpha.11 → 0.2.0-alpha.13

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 { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle } from "@orbat-mapper/control-measures";
1
+ import { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
2
2
  import { Feature, Position } from "geojson";
3
3
 
4
4
  //#region src/gestures/types.d.ts
@@ -54,6 +54,13 @@ interface StyleOptions {
54
54
  pointRadius?: number;
55
55
  text?: TextStyleOptions;
56
56
  }
57
+ /**
58
+ * Text (label) style for a layer. When `labelProperty` is set, adapters also
59
+ * read per-feature label properties: `rotation` (radians, rendered as
60
+ * `π − rotation`), `textSizePixels`/`textSizeZoom`/`textSizeResolution`
61
+ * (ADR-0020), and `textAnchor` (`"start"` = text begins at the label point,
62
+ * `"end"` = text ends there; absent = centered).
63
+ */
57
64
  interface TextStyleOptions {
58
65
  text?: string;
59
66
  labelProperty?: string;
@@ -527,6 +534,13 @@ interface EditSession {
527
534
  readonly options: ControlMeasure["options"];
528
535
  /** Live working style. Copy on read. */
529
536
  readonly style: ControlMeasureStyle | undefined;
537
+ /**
538
+ * Live working text amplifiers (MIL-STD-2525E Appendix L Table L-II). Copy
539
+ * on read. `undefined` when the edit-start measure carried none and none
540
+ * have been set since. Keys are stored as given — canonicalisation to
541
+ * doctrinal field codes happens in the core render seam, not here.
542
+ */
543
+ readonly textAmplifiers: TextAmplifiers | undefined;
530
544
  /** `true` after the first working-copy mutation; back to `false` within tolerance. */
531
545
  readonly dirty: boolean;
532
546
  readonly modes: readonly EditMode[];
@@ -564,6 +578,13 @@ interface EditSession {
564
578
  setSizeAnchor(anchor: SizeAnchor): void;
565
579
  /** Shallow-merge per-measure style into the live working copy. */
566
580
  setStyle(partial: ControlMeasureStyle): void;
581
+ /**
582
+ * Shallow-merge into the live working text amplifiers. A value of
583
+ * `undefined` or an empty/whitespace-only string deletes that key; the
584
+ * working record collapses back to `undefined` once it holds no keys. A
585
+ * silent no-op after the session has settled (like [[setOptions]]).
586
+ */
587
+ setTextAmplifiers(partial: Record<string, string | undefined>): void;
567
588
  /**
568
589
  * Subscribe to working-copy changes. The listener receives an
569
590
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
@@ -1146,11 +1167,12 @@ declare class TacticalDraw {
1146
1167
  private renderMeasuresToGraphics;
1147
1168
  /**
1148
1169
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
1149
- * measures rescale live. Skipped entirely when nothing rendered carries a
1150
- * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
1151
- * would be wasted layer writes. During an active edit the edited measure lives
1152
- * on the preview layer (lifted from graphics), so it is excluded here exactly
1153
- * as in `render()`; the preview's own view-change subscription rescales it.
1170
+ * and not-yet-committed label-sized measures rescale live. Skipped entirely
1171
+ * when nothing rendered still reads live view state — meter geometry with a
1172
+ * captured label size is zoom-independent, so a rebuild would be wasted
1173
+ * layer writes. During an active edit the edited measure lives on the
1174
+ * preview layer (lifted from graphics), so it is excluded here exactly as
1175
+ * in `render()`; the preview's own view-change subscription rescales it.
1154
1176
  */
1155
1177
  private readonly handleViewChange;
1156
1178
  /**
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
1
+ import { canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
2
2
  //#region src/base-map-adapter.ts
3
3
  /**
4
4
  * Abstract base class shared by all `MapAdapter` implementations.
@@ -563,6 +563,21 @@ function measureUsesAdapterResolution(measure) {
563
563
  return measure.options?.[spec.pixels] !== void 0;
564
564
  }
565
565
  /**
566
+ * Whether rendering `measure` through [[renderMeasureForAdapter]] reads live
567
+ * adapter view state — either a pixel-denominated size
568
+ * ([[measureUsesAdapterResolution]]) or draw-time label sizing that
569
+ * [[withCommittedAdapterMeasureOptions]] would still capture (a
570
+ * `capturesLabelSize` kind not yet carrying `labelSizePixels`). The
571
+ * view-change rerender gates key off this so such measures rescale live;
572
+ * committed label-sized measures already carry `labelSizePixels`, stay
573
+ * zoom-independent, and keep skipping the rebuild.
574
+ */
575
+ function measureRendersFromViewState(measure) {
576
+ if (measureUsesAdapterResolution(measure)) return true;
577
+ if (!getControlMeasureMetadata(measure.kind).capturesLabelSize) return false;
578
+ return typeof measure.options?.labelSizePixels !== "number";
579
+ }
580
+ /**
566
581
  * The [[SizeAnchor]] a measure currently encodes (ADR-0020): `screen` when it
567
582
  * carries its kind's pixel-denominated size option, `ground` otherwise. Keys off
568
583
  * the same `PIXEL_SIZE_OPTIONS` table as the bake, so a consumer deciding how to
@@ -617,9 +632,11 @@ function withAdapterMeasureOptions(measure, adapter) {
617
632
  };
618
633
  }
619
634
  /**
620
- * Capture draw-time label sizing options on commit. These are stable feature
621
- * style inputs; adapters scale from them without TacticalDraw re-rendering on
622
- * every zoom.
635
+ * Capture draw-time label sizing options. These are stable feature style
636
+ * inputs; adapters scale from them without TacticalDraw re-rendering on every
637
+ * zoom. No-op if the measure already carries `labelSizePixels` (e.g. already
638
+ * committed), so it's safe to call on every render as well as on commit
639
+ * itself — see [[renderMeasureForAdapter]].
623
640
  */
624
641
  function withCommittedAdapterMeasureOptions(measure, adapter) {
625
642
  if (!getControlMeasureMetadata(measure.kind).capturesLabelSize) return measure;
@@ -641,10 +658,17 @@ function withCommittedAdapterMeasureOptions(measure, adapter) {
641
658
  * "turn a measure into features against the live map" — every TacticalDraw
642
659
  * layer write (graphics reconcile, draw preview, edit preview, commit snapshot)
643
660
  * routes through here so pixel sizing is resolved identically everywhere and no
644
- * call site can forget the injection. See [[withAdapterMeasureOptions]].
661
+ * call site can forget the injection. Composes [[withAdapterMeasureOptions]]
662
+ * (pixel size → `metersPerPixel`) with [[withCommittedAdapterMeasureOptions]]
663
+ * (label size defaults for `capturesLabelSize` kinds), so draw/edit previews
664
+ * get the same label clearance a commit would bake in — the input `measure`
665
+ * is never mutated, and callers that need the persisted/working measure to
666
+ * stay free of these render-only options must keep using that separately
667
+ * (draw/edit commit paths call [[withCommittedAdapterMeasureOptions]]
668
+ * directly to produce the value they persist).
645
669
  */
646
670
  function renderMeasureForAdapter(adapter, measure, opts) {
647
- return renderControlMeasure(withAdapterMeasureOptions(measure, adapter), opts);
671
+ return renderControlMeasure(withAdapterMeasureOptions(withCommittedAdapterMeasureOptions(measure, adapter), adapter), opts);
648
672
  }
649
673
  //#endregion
650
674
  //#region src/tactical-draw/run-interaction.ts
@@ -876,7 +900,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
876
900
  * kinds need no host UI.
877
901
  */
878
902
  function runFixedLengthDraw(options) {
879
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
903
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled } = options;
880
904
  const metadata = getControlMeasureMetadata(kind);
881
905
  const rule = metadata.rule;
882
906
  const min = metadata.minCoordinates;
@@ -916,14 +940,14 @@ function runFixedLengthDraw(options) {
916
940
  return;
917
941
  }
918
942
  const previewThreshold = rule?.minimumPreviewPoints ?? required;
919
- const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
943
+ const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
920
944
  ctx.writeLayer(previewLayer, features);
921
945
  const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
922
946
  const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
923
947
  ctx.writeLayer(guideLayer, guideFeatures);
924
948
  }
925
949
  function rescaleOnViewChange() {
926
- if (!measureUsesAdapterResolution({
950
+ if (!measureRendersFromViewState({
927
951
  kind,
928
952
  options: measureOptions
929
953
  })) return;
@@ -937,7 +961,8 @@ function runFixedLengthDraw(options) {
937
961
  controlPoints: finalPoints,
938
962
  ...measureOptions !== void 0 ? { options: measureOptions } : {},
939
963
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
940
- ...measureProperties !== void 0 ? { properties: measureProperties } : {}
964
+ ...measureProperties !== void 0 ? { properties: measureProperties } : {},
965
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
941
966
  }, adapter);
942
967
  const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
943
968
  return {
@@ -970,13 +995,14 @@ function runFixedLengthDraw(options) {
970
995
  * falls back to a plain Point/LineString outline when the kind's generator
971
996
  * cannot produce a shape yet (under-min count).
972
997
  */
973
- function buildPreviewFeatures$1(adapter, kind, points, measureOptions, measureStyle, renderOpts) {
998
+ function buildPreviewFeatures$1(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts) {
974
999
  const measure = {
975
1000
  id: PREVIEW_CM_ID$1,
976
1001
  kind,
977
1002
  controlPoints: points,
978
1003
  ...measureOptions !== void 0 ? { options: measureOptions } : {},
979
- ...measureStyle !== void 0 ? { style: measureStyle } : {}
1004
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1005
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
980
1006
  };
981
1007
  try {
982
1008
  return renderMeasureForAdapter(adapter, measure, {
@@ -1129,7 +1155,7 @@ const PREVIEW_CURSOR = "crosshair";
1129
1155
  * added point again" as additional gesture-level commit triggers.
1130
1156
  */
1131
1157
  function runVariableLengthDraw(options) {
1132
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
1158
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled } = options;
1133
1159
  const metadata = getControlMeasureMetadata(kind);
1134
1160
  if (typeof metadata.minCoordinates !== "number") throw new Error(`runVariableLengthDraw: kind "${kind}" has no minCoordinates metadata`);
1135
1161
  const min = metadata.minCoordinates;
@@ -1164,7 +1190,8 @@ function runVariableLengthDraw(options) {
1164
1190
  kind,
1165
1191
  controlPoints: initialPoints.map((p) => [...p]),
1166
1192
  ...measureOptions !== void 0 ? { options: measureOptions } : {},
1167
- ...measureStyle !== void 0 ? { style: measureStyle } : {}
1193
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1194
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1168
1195
  }, {
1169
1196
  ...renderOpts,
1170
1197
  validationMode: "throw"
@@ -1287,13 +1314,13 @@ function runVariableLengthDraw(options) {
1287
1314
  points.splice(idx, 0, rubberBand);
1288
1315
  } else points = [...committed];
1289
1316
  if (rule) points = rule.derive(points);
1290
- const previewFeatures = points.length === 0 ? [] : buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, renderOpts);
1317
+ const previewFeatures = points.length === 0 ? [] : buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
1291
1318
  ctx.writeLayer(previewLayer, previewFeatures);
1292
1319
  const guideFeatures = points.length === 0 || !guideEnabled ? [] : buildGuideFeatures(points, isAreaKind, trailingFixed);
1293
1320
  ctx.writeLayer(guideLayer, guideFeatures);
1294
1321
  }
1295
1322
  function rescaleOnViewChange() {
1296
- if (!measureUsesAdapterResolution({
1323
+ if (!measureRendersFromViewState({
1297
1324
  kind,
1298
1325
  options: measureOptions
1299
1326
  })) return;
@@ -1306,7 +1333,8 @@ function runVariableLengthDraw(options) {
1306
1333
  controlPoints: committed.map((p) => [...p]),
1307
1334
  ...measureOptions !== void 0 ? { options: measureOptions } : {},
1308
1335
  ...measureStyle !== void 0 ? { style: measureStyle } : {},
1309
- ...measureProperties !== void 0 ? { properties: measureProperties } : {}
1336
+ ...measureProperties !== void 0 ? { properties: measureProperties } : {},
1337
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1310
1338
  }, adapter);
1311
1339
  const measure = sizeAnchor === "screen" ? built : bakePixelSizeToGround(built, adapter);
1312
1340
  return {
@@ -1357,13 +1385,14 @@ function isValidPosition(value) {
1357
1385
  }
1358
1386
  return true;
1359
1387
  }
1360
- function buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, renderOpts) {
1388
+ function buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts) {
1361
1389
  const measure = {
1362
1390
  id: PREVIEW_CM_ID,
1363
1391
  kind,
1364
1392
  controlPoints: points,
1365
1393
  ...measureOptions !== void 0 ? { options: measureOptions } : {},
1366
- ...measureStyle !== void 0 ? { style: measureStyle } : {}
1394
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1395
+ ...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
1367
1396
  };
1368
1397
  try {
1369
1398
  return renderMeasureForAdapter(adapter, measure, renderOpts).features;
@@ -1996,6 +2025,7 @@ function runEdit(opts) {
1996
2025
  const editStartPoints = measure.controlPoints.map((p) => [...p]);
1997
2026
  const workingPoints = editStartPoints.map((p) => [...p]);
1998
2027
  let workingOptions = { ...measure.options ?? {} };
2028
+ let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
1999
2029
  let modes = requestedModes;
2000
2030
  let dirty = false;
2001
2031
  let nonGeometryDirty = false;
@@ -2084,6 +2114,8 @@ function runEdit(opts) {
2084
2114
  options: workingOptions,
2085
2115
  controlPoints: workingPoints.map((p) => [...p])
2086
2116
  };
2117
+ if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2118
+ else delete working.textAmplifiers;
2087
2119
  let features;
2088
2120
  try {
2089
2121
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -2099,7 +2131,7 @@ function runEdit(opts) {
2099
2131
  }
2100
2132
  function rescaleOnViewChange() {
2101
2133
  if (ctx.settled) return;
2102
- if (measureUsesAdapterResolution({
2134
+ if (measureRendersFromViewState({
2103
2135
  kind: measure.kind,
2104
2136
  options: workingOptions
2105
2137
  })) renderPreview();
@@ -2135,6 +2167,8 @@ function runEdit(opts) {
2135
2167
  options,
2136
2168
  controlPoints: workingPoints.map((p) => [...p])
2137
2169
  };
2170
+ if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2171
+ else delete working.textAmplifiers;
2138
2172
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2139
2173
  return {
2140
2174
  measure: cloneControlMeasure(committed),
@@ -2186,7 +2220,7 @@ function runEdit(opts) {
2186
2220
  * - Reject restores the working state to the pre-gesture snapshot and
2187
2221
  * recomputes `dirty`. Close/abort settle the session as normal.
2188
2222
  */
2189
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered) {
2223
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers) {
2190
2224
  if (ctx.settled) return;
2191
2225
  let rejectRequested = false;
2192
2226
  let closeRequested = false;
@@ -2218,6 +2252,7 @@ function runEdit(opts) {
2218
2252
  replaceWorkingOptions(previousOptions);
2219
2253
  currentStyle = previousStyle;
2220
2254
  sizeAnchor = previousSizeAnchor;
2255
+ workingTextAmplifiers = previousTextAmplifiers;
2221
2256
  nonGeometryDirty = previousNonGeometryDirty;
2222
2257
  renderPreview();
2223
2258
  renderHandlesLayer();
@@ -2242,6 +2277,7 @@ function runEdit(opts) {
2242
2277
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
2243
2278
  const previousStyle = currentStyle;
2244
2279
  const previousNonGeometryDirty = nonGeometryDirty;
2280
+ const previousTextAmplifiers = workingTextAmplifiers;
2245
2281
  const previousRendered = rebuildRenderedWith(previousOptions);
2246
2282
  replaceWorkingPoints(nextPoints);
2247
2283
  let currentRendered;
@@ -2259,7 +2295,7 @@ function runEdit(opts) {
2259
2295
  recomputeDirty();
2260
2296
  dragInFlight = false;
2261
2297
  drainPendingModes();
2262
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
2298
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2263
2299
  }
2264
2300
  function setOptions(partial) {
2265
2301
  if (ctx.settled) return;
@@ -2267,6 +2303,7 @@ function runEdit(opts) {
2267
2303
  const previousOptions = { ...workingOptions ?? {} };
2268
2304
  const previousStyle = currentStyle;
2269
2305
  const previousNonGeometryDirty = nonGeometryDirty;
2306
+ const previousTextAmplifiers = workingTextAmplifiers;
2270
2307
  const previousRendered = rebuildRendered();
2271
2308
  replaceWorkingOptions({
2272
2309
  ...workingOptions ?? {},
@@ -2282,7 +2319,7 @@ function runEdit(opts) {
2282
2319
  renderPreview();
2283
2320
  nonGeometryDirty = true;
2284
2321
  dirty = true;
2285
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
2322
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2286
2323
  }
2287
2324
  function setStyle(partial) {
2288
2325
  if (ctx.settled) return;
@@ -2290,6 +2327,7 @@ function runEdit(opts) {
2290
2327
  const previousOptions = { ...workingOptions ?? {} };
2291
2328
  const previousStyle = currentStyle;
2292
2329
  const previousNonGeometryDirty = nonGeometryDirty;
2330
+ const previousTextAmplifiers = workingTextAmplifiers;
2293
2331
  const previousRendered = rebuildRendered();
2294
2332
  currentStyle = {
2295
2333
  ...currentStyle ?? {},
@@ -2305,7 +2343,51 @@ function runEdit(opts) {
2305
2343
  renderPreview();
2306
2344
  nonGeometryDirty = true;
2307
2345
  dirty = true;
2308
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
2346
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2347
+ }
2348
+ /**
2349
+ * Shallow-merge into the live working text amplifiers. Mirrors
2350
+ * [[setOptions]] exactly (dirty tracking, re-render, change emission) —
2351
+ * see that function for the shared shape. A merge value of `undefined` or
2352
+ * an empty/whitespace-only string deletes the key; the record collapses to
2353
+ * `undefined` once empty so `renderPreview`/`rebuildRenderedWith` omit the
2354
+ * key rather than persist `{}`.
2355
+ *
2356
+ * Each patch key resolves to its canonical field key and evicts every
2357
+ * working entry for the same field + suffix — including friendly-alias
2358
+ * twins (`uniqueDesignation` for `T`) — so clearing or replacing via a
2359
+ * canonical key cannot be undone by an alias-backed value surviving in the
2360
+ * working record. Non-empty values are stored under the canonical key;
2361
+ * unknown keys keep literal behavior.
2362
+ */
2363
+ function setTextAmplifiers(partial) {
2364
+ if (ctx.settled) return;
2365
+ const previousPoints = workingPoints.map((p) => [...p]);
2366
+ const previousOptions = { ...workingOptions ?? {} };
2367
+ const previousStyle = currentStyle;
2368
+ const previousNonGeometryDirty = nonGeometryDirty;
2369
+ const previousTextAmplifiers = workingTextAmplifiers;
2370
+ const previousRendered = rebuildRendered();
2371
+ const merged = { ...workingTextAmplifiers ?? {} };
2372
+ for (const [key, value] of Object.entries(partial)) {
2373
+ const canonicalKey = canonicalTextAmplifierKey(key);
2374
+ if (canonicalKey) {
2375
+ for (const existing of Object.keys(merged)) if (canonicalTextAmplifierKey(existing) === canonicalKey) delete merged[existing];
2376
+ } else delete merged[key];
2377
+ if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
2378
+ }
2379
+ workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
2380
+ let currentRendered;
2381
+ try {
2382
+ currentRendered = rebuildRendered();
2383
+ } catch (err) {
2384
+ workingTextAmplifiers = previousTextAmplifiers;
2385
+ throw err;
2386
+ }
2387
+ renderPreview();
2388
+ nonGeometryDirty = true;
2389
+ dirty = true;
2390
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2309
2391
  }
2310
2392
  /**
2311
2393
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2321,12 +2403,13 @@ function runEdit(opts) {
2321
2403
  const previousStyle = currentStyle;
2322
2404
  const previousNonGeometryDirty = nonGeometryDirty;
2323
2405
  const previousSizeAnchor = sizeAnchor;
2406
+ const previousTextAmplifiers = workingTextAmplifiers;
2324
2407
  const previousRendered = rebuildRendered();
2325
2408
  sizeAnchor = next;
2326
2409
  const currentRendered = rebuildRendered();
2327
2410
  nonGeometryDirty = true;
2328
2411
  dirty = true;
2329
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered);
2412
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers);
2330
2413
  }
2331
2414
  /** Apply a translate gesture by shifting every working point by `delta`. */
2332
2415
  function commitTranslate(delta) {
@@ -2384,6 +2467,9 @@ function runEdit(opts) {
2384
2467
  get style() {
2385
2468
  return currentStyle ? { ...currentStyle } : void 0;
2386
2469
  },
2470
+ get textAmplifiers() {
2471
+ return workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0;
2472
+ },
2387
2473
  get dirty() {
2388
2474
  return dirty;
2389
2475
  },
@@ -2415,6 +2501,9 @@ function runEdit(opts) {
2415
2501
  setStyle(partial) {
2416
2502
  setStyle(partial);
2417
2503
  },
2504
+ setTextAmplifiers(partial) {
2505
+ setTextAmplifiers(partial);
2506
+ },
2418
2507
  setSizeAnchor(next) {
2419
2508
  setSizeAnchor(next);
2420
2509
  },
@@ -2957,7 +3046,7 @@ function runGroupTransform(opts) {
2957
3046
  }
2958
3047
  function rescaleOnViewChange() {
2959
3048
  if (ctx.settled) return;
2960
- if (members.some((m) => measureUsesAdapterResolution({
3049
+ if (members.some((m) => measureRendersFromViewState({
2961
3050
  kind: m.measure.kind,
2962
3051
  options: m.measure.options
2963
3052
  }))) renderAllPreviews();
@@ -3529,17 +3618,18 @@ var TacticalDraw = class {
3529
3618
  }
3530
3619
  /**
3531
3620
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
3532
- * measures rescale live. Skipped entirely when nothing rendered carries a
3533
- * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
3534
- * would be wasted layer writes. During an active edit the edited measure lives
3535
- * on the preview layer (lifted from graphics), so it is excluded here exactly
3536
- * as in `render()`; the preview's own view-change subscription rescales it.
3621
+ * and not-yet-committed label-sized measures rescale live. Skipped entirely
3622
+ * when nothing rendered still reads live view state — meter geometry with a
3623
+ * captured label size is zoom-independent, so a rebuild would be wasted
3624
+ * layer writes. During an active edit the edited measure lives on the
3625
+ * preview layer (lifted from graphics), so it is excluded here exactly as
3626
+ * in `render()`; the preview's own view-change subscription rescales it.
3537
3627
  */
3538
3628
  handleViewChange = () => {
3539
3629
  if (this.destroyed || !this.hasRendered) return;
3540
3630
  const excluded = this.excludedInteractionIds();
3541
3631
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
3542
- if (!visible.some(measureUsesAdapterResolution)) return;
3632
+ if (!visible.some(measureRendersFromViewState)) return;
3543
3633
  this.renderMeasuresToGraphics(visible);
3544
3634
  };
3545
3635
  /**
@@ -3761,6 +3851,7 @@ var TacticalDraw = class {
3761
3851
  measureOptions: draft.options,
3762
3852
  measureStyle: draft.style,
3763
3853
  measureProperties: draft.properties,
3854
+ measureTextAmplifiers: draft.textAmplifiers,
3764
3855
  guide: options?.guide,
3765
3856
  sizeAnchor: options?.sizeAnchor,
3766
3857
  onSettled
@@ -3777,6 +3868,7 @@ var TacticalDraw = class {
3777
3868
  measureOptions: draft.options,
3778
3869
  measureStyle: draft.style,
3779
3870
  measureProperties: draft.properties,
3871
+ measureTextAmplifiers: draft.textAmplifiers,
3780
3872
  guide: options?.guide,
3781
3873
  sizeAnchor: options?.sizeAnchor,
3782
3874
  onSettled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.11",
3
+ "version": "0.2.0-alpha.13",
4
4
  "description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@orbat-mapper/control-measures": "^0.2.0-alpha.8"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.10"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",