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

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-BIlrWk6y.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;
@@ -1443,6 +1452,23 @@ declare class TacticalDraw {
1443
1452
  /** Snapshot of the host's latest authoritative Graphic membership and order. */
1444
1453
  private lastRenderedMeasures;
1445
1454
  private snapGeometryFeatures;
1455
+ /**
1456
+ * Rendered graphics-layer groups, keyed on Graphic snapshot identity. See
1457
+ * {@link renderGraphicGroup} for the invalidation contract; swapped for a
1458
+ * fresh map (not cleared entry-by-entry) so stale entries are collectable.
1459
+ */
1460
+ private graphicGroupCache;
1461
+ /**
1462
+ * Last {@link PointSymbolCapability.generation} observed from `pointSymbols`,
1463
+ * initialised from the capability at construction. Compared at the top of
1464
+ * every {@link reconcileGraphicsStack} so a host calling `invalidate()` after
1465
+ * changing renderer-global config (e.g. milsymbol options) drops the render
1466
+ * cache even though the Graphic snapshots handed to `render()` are
1467
+ * unchanged — otherwise the cache would keep serving pre-invalidation
1468
+ * output and the capability would never be re-consulted. `undefined` when
1469
+ * there is no capability, or the capability doesn't expose `generation`.
1470
+ */
1471
+ private lastSeenPointSymbolGeneration;
1446
1472
  /**
1447
1473
  * Ids of graphics currently carrying a passive selection-highlight outline
1448
1474
  * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
@@ -1462,8 +1488,34 @@ declare class TacticalDraw {
1462
1488
  setSnappingOptions(options?: SnappingOptions): void;
1463
1489
  /** Clear transient host affordances when snapping or its interaction ends. */
1464
1490
  private clearSnapResult;
1465
- /** Render one atomic Graphic group for the engine-neutral graphics stack. */
1491
+ /**
1492
+ * Render one atomic Graphic group for the engine-neutral graphics stack,
1493
+ * reusing the previous render when nothing that feeds it has changed.
1494
+ *
1495
+ * The cache is keyed on the Graphic snapshot's object identity: hosts hand
1496
+ * `render()` a fresh object whenever a graphic's data changes, so a changed
1497
+ * graphic misses by construction. (A host that mutates a Graphic in place and
1498
+ * re-renders the same reference is outside the contract — `render()` has
1499
+ * always been documented as taking snapshots.) Rendered output also depends
1500
+ * on live view state and on the resolved graphics style; the style is fixed
1501
+ * at construction, and every view change drops the whole cache. Crucially,
1502
+ * the view cannot change during a box drag (drag-pan is disabled), so every
1503
+ * graphic outside the selection stays a cache hit for the whole gesture and
1504
+ * per-frame reconcile cost becomes O(selection) rather than O(document).
1505
+ */
1466
1506
  private renderGraphicGroup;
1507
+ /**
1508
+ * Render a Graphic group from scratch and stamp it with a fresh revision,
1509
+ * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
1510
+ * its doc comment for why it isn't a per-instance field).
1511
+ */
1512
+ private buildGraphicGroup;
1513
+ /**
1514
+ * Drop every cached render. Called whenever an input shared by all graphic
1515
+ * renders changes — the live view state, or (via {@link reconcileGraphicsStack})
1516
+ * the point-symbol capability's `generation`.
1517
+ */
1518
+ private invalidateGraphicGroupCache;
1467
1519
  /** Submit one complete grouped desired state for the host stack plus overrides. */
1468
1520
  private reconcileGraphicsStack;
1469
1521
  /** 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();
@@ -4768,6 +4769,42 @@ function boundProperty(key, value) {
4768
4769
  return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
4769
4770
  }
4770
4771
  //#endregion
4772
+ //#region src/tactical-draw/frame-coalescer.ts
4773
+ /**
4774
+ * @param run Callback invoked with the latest scheduled args, at most once per frame.
4775
+ */
4776
+ function createFrameCoalescer(run) {
4777
+ const raf = globalThis.requestAnimationFrame?.bind(globalThis);
4778
+ const cancelRaf = globalThis.cancelAnimationFrame?.bind(globalThis);
4779
+ if (!raf || !cancelRaf) return {
4780
+ schedule(...args) {
4781
+ run(...args);
4782
+ },
4783
+ cancel() {}
4784
+ };
4785
+ let handle = null;
4786
+ let latestArgs = null;
4787
+ return {
4788
+ schedule(...args) {
4789
+ latestArgs = args;
4790
+ if (handle !== null) return;
4791
+ handle = raf(() => {
4792
+ handle = null;
4793
+ const pending = latestArgs;
4794
+ latestArgs = null;
4795
+ if (pending) run(...pending);
4796
+ });
4797
+ },
4798
+ cancel() {
4799
+ if (handle !== null) {
4800
+ cancelRaf(handle);
4801
+ handle = null;
4802
+ }
4803
+ latestArgs = null;
4804
+ }
4805
+ };
4806
+ }
4807
+ //#endregion
4771
4808
  //#region src/tactical-draw/transform-controller.ts
4772
4809
  /** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
4773
4810
  const MIN_POINT_SYMBOL_SIZE = .1;
@@ -5479,12 +5516,14 @@ function runGroupTransform(opts) {
5479
5516
  renderHandlesLayer();
5480
5517
  recomputeDirty();
5481
5518
  }
5519
+ const boxDragPaintCoalescer = createFrameCoalescer(paintBoxDragSnapshot);
5482
5520
  const onDriverMove = (pixel, info) => {
5483
5521
  if (ctx.settled) return;
5484
- if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
5522
+ if (activeBoxDrag) boxDragPaintCoalescer.schedule(pixel, info?.shiftKey);
5485
5523
  };
5486
5524
  const onDriverUp = (pixel, info) => {
5487
5525
  if (!activeBoxDrag) return;
5526
+ boxDragPaintCoalescer.cancel();
5488
5527
  if (ctx.settled) {
5489
5528
  activeBoxDrag = null;
5490
5529
  dragInFlight = false;
@@ -5545,6 +5584,7 @@ function runGroupTransform(opts) {
5545
5584
  if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
5546
5585
  },
5547
5586
  extraTeardown: () => {
5587
+ boxDragPaintCoalescer.cancel();
5548
5588
  if (activeBoxDrag !== null) {
5549
5589
  driver.setDragPanEnabled(true);
5550
5590
  activeBoxDrag = null;
@@ -5930,6 +5970,17 @@ var GraphicStackCoordinator = class {
5930
5970
  //#endregion
5931
5971
  //#region src/tactical-draw/tactical-draw.ts
5932
5972
  /**
5973
+ * Source of {@link FeatureGroup.revision} stamps. Module-scoped rather than a
5974
+ * per-instance counter: adapters keep per-group bundles on host-supplied layer
5975
+ * handles, and `destroy()` intentionally leaves those layers untouched so a
5976
+ * host can attach a fresh façade to the same adapter/layer for reuse. An
5977
+ * instance counter would restart at 1 on the new façade, so a bundle the
5978
+ * previous façade stored at revision 1 — with different content — would
5979
+ * compare equal and the adapter would wrongly skip the repaint. A counter
5980
+ * shared across every façade in the JS realm guarantees revisions never repeat.
5981
+ */
5982
+ let nextGraphicGroupRevision = 0;
5983
+ /**
5933
5984
  * `TacticalDraw` façade — slice #6 (issue #45).
5934
5985
  *
5935
5986
  * Provides the constructor + lifecycle + `render(measures)` reconcile path.
@@ -5998,6 +6049,23 @@ var TacticalDraw = class {
5998
6049
  lastRenderedMeasures = [];
5999
6050
  snapGeometryFeatures = [];
6000
6051
  /**
6052
+ * Rendered graphics-layer groups, keyed on Graphic snapshot identity. See
6053
+ * {@link renderGraphicGroup} for the invalidation contract; swapped for a
6054
+ * fresh map (not cleared entry-by-entry) so stale entries are collectable.
6055
+ */
6056
+ graphicGroupCache = /* @__PURE__ */ new WeakMap();
6057
+ /**
6058
+ * Last {@link PointSymbolCapability.generation} observed from `pointSymbols`,
6059
+ * initialised from the capability at construction. Compared at the top of
6060
+ * every {@link reconcileGraphicsStack} so a host calling `invalidate()` after
6061
+ * changing renderer-global config (e.g. milsymbol options) drops the render
6062
+ * cache even though the Graphic snapshots handed to `render()` are
6063
+ * unchanged — otherwise the cache would keep serving pre-invalidation
6064
+ * output and the capability would never be re-consulted. `undefined` when
6065
+ * there is no capability, or the capability doesn't expose `generation`.
6066
+ */
6067
+ lastSeenPointSymbolGeneration;
6068
+ /**
6001
6069
  * Ids of graphics currently carrying a passive selection-highlight outline
6002
6070
  * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
6003
6071
  * re-projected on every view change and re-evaluated at the end of every
@@ -6017,6 +6085,7 @@ var TacticalDraw = class {
6017
6085
  this.defaultInteractionStyle = options?.interactionStyle;
6018
6086
  this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
6019
6087
  this.pointSymbols = options?.pointSymbols;
6088
+ this.lastSeenPointSymbolGeneration = this.pointSymbols?.generation;
6020
6089
  this.snappingOptions = options?.snapping;
6021
6090
  this.resolveSnap = createSnapResolver(adapter, () => this.lastRenderedMeasures, () => this.snapGeometryFeatures, () => this.snappingOptions);
6022
6091
  const owned = /* @__PURE__ */ new Set();
@@ -6044,27 +6113,68 @@ var TacticalDraw = class {
6044
6113
  clearSnapResult() {
6045
6114
  this.snappingOptions?.onSnap?.(null);
6046
6115
  }
6047
- /** Render one atomic Graphic group for the engine-neutral graphics stack. */
6116
+ /**
6117
+ * Render one atomic Graphic group for the engine-neutral graphics stack,
6118
+ * reusing the previous render when nothing that feeds it has changed.
6119
+ *
6120
+ * The cache is keyed on the Graphic snapshot's object identity: hosts hand
6121
+ * `render()` a fresh object whenever a graphic's data changes, so a changed
6122
+ * graphic misses by construction. (A host that mutates a Graphic in place and
6123
+ * re-renders the same reference is outside the contract — `render()` has
6124
+ * always been documented as taking snapshots.) Rendered output also depends
6125
+ * on live view state and on the resolved graphics style; the style is fixed
6126
+ * at construction, and every view change drops the whole cache. Crucially,
6127
+ * the view cannot change during a box drag (drag-pan is disabled), so every
6128
+ * graphic outside the selection stays a cache hit for the whole gesture and
6129
+ * per-frame reconcile cost becomes O(selection) rather than O(document).
6130
+ */
6048
6131
  renderGraphicGroup(graphic) {
6049
- const renderOpts = { graphicsStyle: this.graphicsStyle };
6132
+ const cached = this.graphicGroupCache.get(graphic);
6133
+ if (cached) return cached;
6134
+ const group = this.buildGraphicGroup(graphic);
6135
+ this.graphicGroupCache.set(graphic, group);
6136
+ return group;
6137
+ }
6138
+ /**
6139
+ * Render a Graphic group from scratch and stamp it with a fresh revision,
6140
+ * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
6141
+ * its doc comment for why it isn't a per-instance field).
6142
+ */
6143
+ buildGraphicGroup(graphic) {
6144
+ const revision = ++nextGraphicGroupRevision;
6050
6145
  if (isPointSymbol(graphic)) {
6051
6146
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
6052
6147
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
6053
6148
  return {
6054
6149
  id: graphic.id,
6055
6150
  features: [plan.feature],
6056
- iconResources: [plan.resource]
6151
+ iconResources: [plan.resource],
6152
+ revision
6057
6153
  };
6058
6154
  }
6059
- const render = renderMeasureForAdapter(this.adapter, graphic, renderOpts);
6155
+ const render = renderMeasureForAdapter(this.adapter, graphic, { graphicsStyle: this.graphicsStyle });
6060
6156
  return {
6061
6157
  id: graphic.id,
6062
6158
  features: render.features,
6063
- iconResources: []
6159
+ iconResources: [],
6160
+ revision
6064
6161
  };
6065
6162
  }
6163
+ /**
6164
+ * Drop every cached render. Called whenever an input shared by all graphic
6165
+ * renders changes — the live view state, or (via {@link reconcileGraphicsStack})
6166
+ * the point-symbol capability's `generation`.
6167
+ */
6168
+ invalidateGraphicGroupCache() {
6169
+ this.graphicGroupCache = /* @__PURE__ */ new WeakMap();
6170
+ }
6066
6171
  /** Submit one complete grouped desired state for the host stack plus overrides. */
6067
6172
  reconcileGraphicsStack(graphics = this.lastRenderedMeasures) {
6173
+ const currentGeneration = this.pointSymbols?.generation;
6174
+ if (currentGeneration !== this.lastSeenPointSymbolGeneration) {
6175
+ this.invalidateGraphicGroupCache();
6176
+ this.lastSeenPointSymbolGeneration = currentGeneration;
6177
+ }
6068
6178
  const groups = this.graphicStack.desiredGroups(graphics, (graphic) => this.renderGraphicGroup(graphic));
6069
6179
  this.adapter.setLayerFeatureGroups(this.graphicsLayer, groups);
6070
6180
  this.snapGeometryFeatures = groups.flatMap((group) => group.features);
@@ -6093,6 +6203,7 @@ var TacticalDraw = class {
6093
6203
  */
6094
6204
  handleViewChange = () => {
6095
6205
  if (this.destroyed || !this.hasRendered) return;
6206
+ this.invalidateGraphicGroupCache();
6096
6207
  this.renderHighlights();
6097
6208
  const excluded = this.graphicsStackExcludedIds();
6098
6209
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
@@ -188,6 +188,15 @@ interface FeatureGroup {
188
188
  id: string;
189
189
  features: readonly Feature[];
190
190
  iconResources: readonly IconResource[];
191
+ /**
192
+ * Monotonic stamp identifying this group's rendered content, when the
193
+ * producer supplies one. Two groups with the same `id` and the same
194
+ * `revision` carry byte-identical `features` and `iconResources`, so an
195
+ * adapter may skip any diff or upload work for them. A missing `revision`
196
+ * means "unknown" — adapters must fall back to their own comparison and
197
+ * must never treat two absent stamps as equal.
198
+ */
199
+ revision?: number;
191
200
  }
192
201
  /**
193
202
  * 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-BIlrWk6y.mjs";
2
2
  import { Feature } from "geojson";
3
3
 
4
4
  //#region src/testing/feature-group-conformance.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.37",
3
+ "version": "0.2.0-alpha.38",
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",