@orbat-mapper/tactical-draw-adapter-maplibre 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -39,7 +39,11 @@ declare class PinResources {
39
39
  private invalidateDepth;
40
40
  private restore;
41
41
  constructor(map: Map$1);
42
- setRoster(pins: readonly PinRender[]): void;
42
+ /**
43
+ * `pixelRatio` is the device pixels per CSS pixel the atlas is rasterized
44
+ * at; it is part of the roster key, so a change rebuilds the atlas.
45
+ */
46
+ setRoster(pins: readonly PinRender[], pixelRatio?: number): void;
43
47
  load(gl: WebGL2RenderingContext): Promise<PinResourceSnapshot | undefined>;
44
48
  dispose(): void;
45
49
  }
@@ -143,6 +147,13 @@ interface MapLibreLayerHandle {
143
147
  sourceId: string;
144
148
  /** MapLibre layer ids created for this logical layer (fill, line, circle, symbol) */
145
149
  mlLayerIds: string[];
150
+ /**
151
+ * Anchor for overlay-band bundle layers while terrain is active — this
152
+ * handle's own `-circle` layer (ADR-0057). Stored explicitly rather than
153
+ * derived from `mlLayerIds` by position/suffix so the terrain-banding code
154
+ * doesn't depend on `createLayer`'s layer-creation order.
155
+ */
156
+ overlayAnchorId: string;
146
157
  groupBundles: Map<string, MapLibreGroupBundle>;
147
158
  groupOrder: string[];
148
159
  nextGroupBundleId: number;
@@ -155,6 +166,8 @@ interface MapLibreLayerHandle {
155
166
  * skip calling it at all rather than pay for that every drag frame.
156
167
  */
157
168
  lastPinBatch?: readonly PinRender[];
169
+ /** The pixel ratio passed alongside `lastPinBatch`. */
170
+ lastPinPixelRatio?: number;
158
171
  }
159
172
  type PaintChannel = "fill" | "fill-pattern" | "line" | "circle" | "icon" | "text";
160
173
  interface MapLibreGroupBundle {
@@ -214,13 +227,41 @@ interface MapLibreAdapterOptions {
214
227
  * the refit until the animation ends.
215
228
  */
216
229
  viewChangeMode?: "continuous" | "settle";
230
+ /**
231
+ * While MapLibre terrain is active, paint fill/line layers in one band below
232
+ * circle/icon/text/pin layers so terrain draping stays in few passes
233
+ * (ADR-0057). `false` keeps exact per-graphic paint order under terrain, at
234
+ * one draping pass per graphic. @default true
235
+ */
236
+ terrainBanding?: boolean;
237
+ /**
238
+ * Device pixels per CSS pixel at which pin symbols are rasterized into their
239
+ * texture atlas. Defaults to the map's pixel ratio capped at 2, which bounds
240
+ * texture memory on dense screens. Set it (e.g. to the map's `pixelRatio`)
241
+ * for offscreen renders above 2× so pins stay as sharp as the output.
242
+ */
243
+ pinPixelRatio?: number;
217
244
  }
218
245
  declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLibreEventToken, MapLibreViewToken> {
219
246
  private map;
220
247
  private readonly viewChangeMode;
221
248
  private readonly pendingIconImages;
222
249
  readonly scalesGroundPointSymbolsNatively = true;
250
+ private terrainActive;
251
+ private terrainBanding;
252
+ private readonly pinPixelRatio?;
253
+ private readonly handleTerrainChange;
223
254
  constructor(map: maplibregl.Map, options?: MapLibreAdapterOptions);
255
+ /** Switches `terrainBanding` at runtime, re-banding every group if the effective state changes. */
256
+ setTerrainBanding(enabled: boolean): void;
257
+ protected destroyEngineResources(): void;
258
+ /**
259
+ * Insertion anchor for a new bundle layer: below the handle's layers, or —
260
+ * for an overlay-band layer under terrain — below the overlay anchor, so it
261
+ * never lands between draped layers and fragments a render-to-texture stack
262
+ * (ADR-0057).
263
+ */
264
+ private insertionAnchor;
224
265
  private toContinuousLng;
225
266
  protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
226
267
  protected destroyLayer(handle: MapLibreLayerHandle): void;
@@ -234,6 +275,21 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
234
275
  private updateSurfaceSizing;
235
276
  private buildGroupLayer;
236
277
  private updateGroupBundleSource;
278
+ /**
279
+ * Reorders bundle layers below `handle.mlLayerIds`. Walks groups back to
280
+ * front with a `beforeId` cursor, the same LIS-based stable-subset
281
+ * optimization as before.
282
+ *
283
+ * While terrain is active (ADR-0057), bundle layers instead split into two
284
+ * independently-tracked bands so every graphic contributes at most two
285
+ * render-to-texture stacks instead of one per graphic: a draped band
286
+ * (image overlays, fill/fill-pattern/line surfaces) anchored below the
287
+ * handle's own `-fill` layer, and an overlay band (the pin layer,
288
+ * circle/icon/text surfaces) anchored below the handle's own `-circle`
289
+ * layer. Without terrain, both cursors stay equal throughout, collapsing
290
+ * back to the single-cursor algorithm and the exact atomic per-graphic
291
+ * order (ADR-0039).
292
+ */
237
293
  private orderGroupBundles;
238
294
  private nativeLayerIds;
239
295
  /**
package/dist/index.mjs CHANGED
@@ -81,11 +81,15 @@ var PinResources = class {
81
81
  map.on("webglcontextrestored", this.restore);
82
82
  map.on("move", this.invalidateDepth);
83
83
  }
84
- setRoster(pins) {
84
+ /**
85
+ * `pixelRatio` is the device pixels per CSS pixel the atlas is rasterized
86
+ * at; it is part of the roster key, so a change rebuilds the atlas.
87
+ */
88
+ setRoster(pins, pixelRatio = 1) {
85
89
  this.needsDepth = pins.some((pin) => pin.presentation.occlusion === "terrain");
86
90
  const resources = /* @__PURE__ */ new Map();
87
91
  for (const pin of pins) {
88
- const scale = pin.presentation.sizePixels / pin.octagonSize * Math.min(devicePixelRatio, 2);
92
+ const scale = pin.presentation.sizePixels / pin.octagonSize * pixelRatio;
89
93
  if (scale > (resources.get(pin.resource.id)?.scale ?? 0)) resources.set(pin.resource.id, {
90
94
  pin,
91
95
  scale
@@ -239,14 +243,15 @@ void main() {
239
243
  else if(kind < 1.5) {
240
244
  vec2 delta = tp-gp; float len = length(delta);
241
245
  vec2 dir = len > .0001 ? delta/len : vec2(0,1);
242
- vec2 p = mix(gp,tp,c.y) + vec2(-dir.y,dir.x) * (c.x-.5)*a_shape.x;
246
+ // Widen by 1px so the fragment edge fade has room to run.
247
+ vec2 p = mix(gp,tp,c.y) + vec2(-dir.y,dir.x) * (c.x-.5)*(a_shape.x+1.);
243
248
  clip = vec4(p/u_view*2., mix(ground.z/ground.w,target.z/target.w,c.y), 1.);
244
249
  offset = vec2(0);
245
250
  } else { offset = (c - vec2(.5,0)) * a_shape.xy + a_shape.zw; }
246
251
  clip.xy += offset * 2. / u_view * clip.w;
247
252
  if (ground.w <= 0. || target.w <= 0.) clip = vec4(2,2,2,1);
248
253
  gl_Position=clip;
249
- v_local=(c-.5)*a_shape.xy;
254
+ v_local=(c-.5)*(a_shape.xy + (kind > .5 && kind < 1.5 ? vec2(1,0) : vec2(0)));
250
255
  v_uv=mix(a_uv.xy,a_uv.zw,vec2(c.x,1.-c.y));
251
256
  v_bounds=a_uv; v_color=a_color; v_shape=a_shape; v_flags=a_flags; v_radius=a_target.w;
252
257
  }`;
@@ -288,6 +293,10 @@ void main() {
288
293
  if(kind < .5) {
289
294
  float edge=length(v_local)-v_shape.x*.5;
290
295
  paint.a *= 1.-smoothstep(-1.,0.,edge);
296
+ } else if(kind < 1.5) {
297
+ // World-mode stems lean with perspective; fade the long edges over one device pixel.
298
+ float aa=max(fwidth(v_local.x),1e-4);
299
+ paint.a *= clamp((v_shape.x*.5-abs(v_local.x))/aa+.5,0.,1.);
291
300
  } else if(kind > 4.5) {
292
301
  // One optional halo instance; reuse the canonical atlas, including text and HQ staff.
293
302
  vec2 size = v_shape.xy - vec2(2. * v_radius);
@@ -1305,13 +1314,44 @@ function coordinatesEqual(a, b) {
1305
1314
  }
1306
1315
  return true;
1307
1316
  }
1308
- function firstBundleLayerId(bundle) {
1309
- for (const overlay of bundle.overlays.values()) return overlay.layerId;
1317
+ /**
1318
+ * Paint channels MapLibre renders into a terrain render-to-texture stack
1319
+ * (background/fill/line/raster/hillshade) rather than directly onto the
1320
+ * screen. See ADR-0057.
1321
+ */
1322
+ const DRAPED_CHANNELS = new Set([
1323
+ "fill",
1324
+ "fill-pattern",
1325
+ "line"
1326
+ ]);
1327
+ function isDrapedChannel(channel) {
1328
+ return DRAPED_CHANNELS.has(channel);
1329
+ }
1330
+ /**
1331
+ * Bottom-most layer of a bundle, used as the next (earlier) group's anchor in
1332
+ * `orderGroupBundles`. With a `band` (terrain active, ADR-0057), only that
1333
+ * band's layers count: image overlays are always draped, the pin layer is
1334
+ * always overlay, and paint surfaces follow `isDrapedChannel`.
1335
+ */
1336
+ function firstBundleLayerId(bundle, band) {
1337
+ if (bundle.pinLayer && band !== "draped") return bundle.pinLayer.id;
1338
+ if (band !== "overlay") for (const overlay of bundle.overlays.values()) return overlay.layerId;
1310
1339
  for (const key of bundle.paintOrder) {
1311
1340
  const surface = bundle.paintSurfaces.get(key);
1312
- if (surface) return surface.layerId;
1341
+ if (!surface) continue;
1342
+ if (!band || isDrapedChannel(surface.channel) === (band === "draped")) return surface.layerId;
1313
1343
  }
1314
1344
  }
1345
+ /** Hides an always-empty handle-level anchor layer; see `createLayer`. */
1346
+ function hiddenLayer(layer) {
1347
+ return {
1348
+ ...layer,
1349
+ layout: {
1350
+ ..."layout" in layer ? layer.layout : {},
1351
+ visibility: "none"
1352
+ }
1353
+ };
1354
+ }
1315
1355
  function sameOrder(a, b) {
1316
1356
  if (a.length !== b.length) return false;
1317
1357
  for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
@@ -1915,10 +1955,40 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1915
1955
  viewChangeMode;
1916
1956
  pendingIconImages = /* @__PURE__ */ new Set();
1917
1957
  scalesGroundPointSymbolsNatively = true;
1958
+ terrainActive = false;
1959
+ terrainBanding;
1960
+ pinPixelRatio;
1961
+ handleTerrainChange = () => {
1962
+ const active = this.terrainBanding && !!this.map.getTerrain?.();
1963
+ if (active === this.terrainActive) return;
1964
+ this.terrainActive = active;
1965
+ for (const handle of this.layers.values()) this.orderGroupBundles(handle, handle.groupOrder, handle.groupOrder, new Set(handle.groupOrder));
1966
+ };
1918
1967
  constructor(map, options = {}) {
1919
1968
  super();
1920
1969
  this.map = map;
1921
1970
  this.viewChangeMode = options.viewChangeMode ?? "continuous";
1971
+ this.terrainBanding = options.terrainBanding ?? true;
1972
+ this.pinPixelRatio = options.pinPixelRatio;
1973
+ this.handleTerrainChange();
1974
+ this.map.on("terrain", this.handleTerrainChange);
1975
+ }
1976
+ /** Switches `terrainBanding` at runtime, re-banding every group if the effective state changes. */
1977
+ setTerrainBanding(enabled) {
1978
+ this.terrainBanding = enabled;
1979
+ this.handleTerrainChange();
1980
+ }
1981
+ destroyEngineResources() {
1982
+ this.map.off("terrain", this.handleTerrainChange);
1983
+ }
1984
+ /**
1985
+ * Insertion anchor for a new bundle layer: below the handle's layers, or —
1986
+ * for an overlay-band layer under terrain — below the overlay anchor, so it
1987
+ * never lands between draped layers and fragments a render-to-texture stack
1988
+ * (ADR-0057).
1989
+ */
1990
+ insertionAnchor(handle, draped) {
1991
+ return this.terrainActive && !draped ? handle.overlayAnchorId : handle.mlLayerIds[0];
1922
1992
  }
1923
1993
  toContinuousLng(lng) {
1924
1994
  return nearestWorldCopyLng(lng, this.map.getCenter().lng);
@@ -1932,25 +2002,25 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1932
2002
  });
1933
2003
  const mlLayerIds = [];
1934
2004
  const fillId = `${layerId}-fill`;
1935
- this.map.addLayer({
2005
+ this.map.addLayer(hiddenLayer({
1936
2006
  id: fillId,
1937
2007
  type: "fill",
1938
2008
  source: sourceId,
1939
2009
  filter: buildFillFilter(false),
1940
2010
  paint: buildFillPaint(style)
1941
- });
2011
+ }));
1942
2012
  mlLayerIds.push(fillId);
1943
2013
  const fillPatternId = `${layerId}-fill-pattern`;
1944
- this.map.addLayer({
2014
+ this.map.addLayer(hiddenLayer({
1945
2015
  id: fillPatternId,
1946
2016
  type: "fill",
1947
2017
  source: sourceId,
1948
2018
  filter: buildFillFilter(true),
1949
2019
  paint: FILL_PATTERN_PAINT
1950
- });
2020
+ }));
1951
2021
  mlLayerIds.push(fillPatternId);
1952
2022
  const lineId = `${layerId}-line`;
1953
- this.map.addLayer({
2023
+ this.map.addLayer(hiddenLayer({
1954
2024
  id: lineId,
1955
2025
  type: "line",
1956
2026
  source: sourceId,
@@ -1972,28 +2042,29 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1972
2042
  "line-cap": "round",
1973
2043
  "line-join": "round"
1974
2044
  }
1975
- });
2045
+ }));
1976
2046
  mlLayerIds.push(lineId);
1977
2047
  const circleId = `${layerId}-circle`;
1978
- this.map.addLayer({
2048
+ this.map.addLayer(hiddenLayer({
1979
2049
  id: circleId,
1980
2050
  type: "circle",
1981
2051
  source: sourceId,
1982
2052
  filter: buildCirclePointFilter(style),
1983
2053
  paint: buildCirclePaint(style)
1984
- });
2054
+ }));
1985
2055
  mlLayerIds.push(circleId);
1986
2056
  const iconId = `${layerId}-icon`;
1987
- this.map.addLayer(buildIconLayer(iconId, sourceId));
2057
+ this.map.addLayer(hiddenLayer(buildIconLayer(iconId, sourceId)));
1988
2058
  mlLayerIds.push(iconId);
1989
2059
  if (style?.text) {
1990
2060
  const symbolId = `${layerId}-symbol`;
1991
- this.map.addLayer(buildSymbolLayer(symbolId, sourceId, style.text));
2061
+ this.map.addLayer(hiddenLayer(buildSymbolLayer(symbolId, sourceId, style.text)));
1992
2062
  mlLayerIds.push(symbolId);
1993
2063
  }
1994
2064
  return {
1995
2065
  sourceId,
1996
2066
  mlLayerIds,
2067
+ overlayAnchorId: circleId,
1997
2068
  groupBundles: /* @__PURE__ */ new Map(),
1998
2069
  groupOrder: [],
1999
2070
  nextGroupBundleId: 0,
@@ -2038,10 +2109,12 @@ var MapLibreAdapter = class extends BaseMapAdapter {
2038
2109
  const allPins = groups.flatMap((group) => group.pinBatch ?? []);
2039
2110
  if (allPins.length) {
2040
2111
  handle.pinResources ??= new PinResources(this.map);
2112
+ const pixelRatio = this.pinPixelRatio ?? Math.min(this.map.getPixelRatio(), 2);
2041
2113
  const previousBatch = handle.lastPinBatch;
2042
- if (!(previousBatch !== void 0 && previousBatch.length === allPins.length && allPins.every((pin, index) => pin === previousBatch[index]))) {
2043
- handle.pinResources.setRoster(allPins);
2114
+ if (!(previousBatch !== void 0 && handle.lastPinPixelRatio === pixelRatio && previousBatch.length === allPins.length && allPins.every((pin, index) => pin === previousBatch[index]))) {
2115
+ handle.pinResources.setRoster(allPins, pixelRatio);
2044
2116
  handle.lastPinBatch = allPins;
2117
+ handle.lastPinPixelRatio = pixelRatio;
2045
2118
  }
2046
2119
  }
2047
2120
  const desiredGroupIds = new Set(groups.map((group) => group.id));
@@ -2062,7 +2135,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
2062
2135
  if (group.pinBatch) {
2063
2136
  if (!bundle.pinLayer) {
2064
2137
  const custom = new PinLayer(`${bundle.sourceId}-pins`, handle.pinResources, this.emitPinStatus);
2065
- this.map.addLayer(custom, handle.mlLayerIds[0]);
2138
+ this.map.addLayer(custom, this.insertionAnchor(handle, false));
2066
2139
  bundle.pinLayer = custom;
2067
2140
  bundlesNeedingOrder.add(group.id);
2068
2141
  }
@@ -2236,7 +2309,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
2236
2309
  featureId
2237
2310
  ]
2238
2311
  };
2239
- this.map.addLayer(layer, handle.mlLayerIds[0]);
2312
+ this.map.addLayer(layer, this.insertionAnchor(handle, isDrapedChannel(channel)));
2240
2313
  bundle.paintSurfaces.set(key, {
2241
2314
  layerId,
2242
2315
  channel,
@@ -2332,23 +2405,44 @@ var MapLibreAdapter = class extends BaseMapAdapter {
2332
2405
  bundle.sourceInitialized = true;
2333
2406
  if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
2334
2407
  }
2408
+ /**
2409
+ * Reorders bundle layers below `handle.mlLayerIds`. Walks groups back to
2410
+ * front with a `beforeId` cursor, the same LIS-based stable-subset
2411
+ * optimization as before.
2412
+ *
2413
+ * While terrain is active (ADR-0057), bundle layers instead split into two
2414
+ * independently-tracked bands so every graphic contributes at most two
2415
+ * render-to-texture stacks instead of one per graphic: a draped band
2416
+ * (image overlays, fill/fill-pattern/line surfaces) anchored below the
2417
+ * handle's own `-fill` layer, and an overlay band (the pin layer,
2418
+ * circle/icon/text surfaces) anchored below the handle's own `-circle`
2419
+ * layer. Without terrain, both cursors stay equal throughout, collapsing
2420
+ * back to the single-cursor algorithm and the exact atomic per-graphic
2421
+ * order (ADR-0039).
2422
+ */
2335
2423
  orderGroupBundles(handle, previousGroupIds, desiredGroupIds, forcedGroupIds) {
2336
- let beforeId = handle.mlLayerIds[0];
2337
- if (!beforeId) return;
2424
+ const rootBeforeId = handle.mlLayerIds[0];
2425
+ if (!rootBeforeId) return;
2426
+ let drapedBeforeId = rootBeforeId;
2427
+ let overlayBeforeId = this.terrainActive ? handle.overlayAnchorId : rootBeforeId;
2338
2428
  const stableGroupIds = sameOrder(previousGroupIds, desiredGroupIds) ? null : longestStableGroupIds(previousGroupIds, desiredGroupIds);
2339
2429
  for (let index = desiredGroupIds.length - 1; index >= 0; index--) {
2340
2430
  const groupId = desiredGroupIds[index];
2341
2431
  const bundle = handle.groupBundles.get(groupId);
2342
2432
  if (!bundle) continue;
2343
2433
  if (forcedGroupIds.has(groupId) || stableGroupIds !== null && !stableGroupIds.has(groupId)) {
2344
- if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.moveLayer(bundle.pinLayer.id, beforeId);
2345
- for (const overlay of bundle.overlays.values()) if (this.map.getLayer(overlay.layerId)) this.map.moveLayer(overlay.layerId, beforeId);
2434
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.moveLayer(bundle.pinLayer.id, overlayBeforeId);
2435
+ for (const overlay of bundle.overlays.values()) if (this.map.getLayer(overlay.layerId)) this.map.moveLayer(overlay.layerId, drapedBeforeId);
2346
2436
  for (const key of bundle.paintOrder) {
2347
2437
  const surface = bundle.paintSurfaces.get(key);
2348
- if (surface && this.map.getLayer(surface.layerId)) this.map.moveLayer(surface.layerId, beforeId);
2438
+ if (!surface || !this.map.getLayer(surface.layerId)) continue;
2439
+ this.map.moveLayer(surface.layerId, isDrapedChannel(surface.channel) ? drapedBeforeId : overlayBeforeId);
2349
2440
  }
2350
2441
  }
2351
- beforeId = bundle.pinLayer?.id ?? firstBundleLayerId(bundle) ?? beforeId;
2442
+ if (this.terrainActive) {
2443
+ drapedBeforeId = firstBundleLayerId(bundle, "draped") ?? drapedBeforeId;
2444
+ overlayBeforeId = firstBundleLayerId(bundle, "overlay") ?? overlayBeforeId;
2445
+ } else drapedBeforeId = overlayBeforeId = firstBundleLayerId(bundle) ?? drapedBeforeId;
2352
2446
  }
2353
2447
  }
2354
2448
  nativeLayerIds(handle) {
@@ -2589,7 +2683,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
2589
2683
  for (const bundle of handle.groupBundles.values()) for (const surface of bundle.paintSurfaces.values()) this.applyChannelStyle(surface.layerId, surface.channel, style, resolvedStyle, surface.featureId);
2590
2684
  if (style.text && !handle.mlLayerIds.some((id) => id.endsWith("-symbol"))) {
2591
2685
  const symbolId = `${layerId}-symbol`;
2592
- this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text));
2686
+ this.map.addLayer(hiddenLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text)));
2593
2687
  handle.mlLayerIds.push(symbolId);
2594
2688
  }
2595
2689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw-adapter-maplibre",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MapLibre GL JS engine adapter for @orbat-mapper/tactical-draw. Draw and edit tactical control measures (MIL-STD-2525 / APP-6) on a MapLibre map.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@orbat-mapper/control-measures": "^0.27.0",
41
- "@orbat-mapper/tactical-draw": "^0.10.0"
41
+ "@orbat-mapper/tactical-draw": "^0.10.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "maplibre-gl": ">=5.8.0 <7.0.0"