@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.22 → 0.2.0-alpha.24

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
@@ -17,6 +17,10 @@ interface MapLibreGroupBundle {
17
17
  readonly sourceId: string;
18
18
  readonly layerIds: Map<PaintChannel, string>;
19
19
  features: readonly Feature[];
20
+ /** Features most recently submitted to the native source, after MapLibre-specific promotion. */
21
+ sourceFeatures: readonly Feature[];
22
+ /** Whether the native source has received its first complete dataset. */
23
+ sourceInitialized: boolean;
20
24
  /** Unset while the stored value describes older features than `features` holds. */
21
25
  featureSignature: string | undefined;
22
26
  /**
package/dist/index.mjs CHANGED
@@ -28,6 +28,49 @@ function sameOrder(a, b) {
28
28
  return true;
29
29
  }
30
30
  /**
31
+ * Replace the contents of shared-id features while adding/removing ids that
32
+ * entered or left a stable group bundle. Remove stale keys explicitly instead
33
+ * of combining `removeAllProperties` with `addOrUpdateProperties`: MapLibre's
34
+ * tile-index diff treats the former as exclusive and otherwise drops the
35
+ * replacement properties, including the promoted feature id.
36
+ */
37
+ function buildGeoJSONSourceDiff(previousFeatures, nextFeatures) {
38
+ const previousById = new Map(previousFeatures.map((feature) => [feature.id, feature]));
39
+ const nextById = new Map(nextFeatures.map((feature) => [feature.id, feature]));
40
+ const remove = previousFeatures.filter((feature) => !nextById.has(feature.id)).map((feature) => feature.id);
41
+ const add = [];
42
+ const update = [];
43
+ for (const feature of nextFeatures) {
44
+ const previous = previousById.get(feature.id);
45
+ if (!previous) {
46
+ add.push(feature);
47
+ continue;
48
+ }
49
+ if (feature.geometry === null) {
50
+ remove.push(feature.id);
51
+ add.push(feature);
52
+ continue;
53
+ }
54
+ const previousProperties = previous.properties ?? {};
55
+ const nextProperties = feature.properties ?? {};
56
+ const removeProperties = Object.keys(previousProperties).filter((key) => !Object.hasOwn(nextProperties, key));
57
+ update.push({
58
+ id: feature.id,
59
+ newGeometry: feature.geometry,
60
+ ...removeProperties.length > 0 ? { removeProperties } : {},
61
+ addOrUpdateProperties: Object.entries(nextProperties).map(([key, value]) => ({
62
+ key,
63
+ value
64
+ }))
65
+ });
66
+ }
67
+ return {
68
+ ...remove.length > 0 ? { remove } : {},
69
+ ...add.length > 0 ? { add } : {},
70
+ ...update.length > 0 ? { update } : {}
71
+ };
72
+ }
73
+ /**
31
74
  * Find the largest subset of bundles whose relative order already matches the
32
75
  * desired stack. Reorders then move only the remaining bundles.
33
76
  */
@@ -189,7 +232,7 @@ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
189
232
  * matching `getResolution`); the linear ramp between stops approximates the
190
233
  * exponential exactly closely enough at 1-zoom granularity.
191
234
  */
192
- function buildTextSizeExpression(text) {
235
+ function textSizeAtZoom(text, zoom) {
193
236
  const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
194
237
  const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
195
238
  const screenOrDefault = [
@@ -197,43 +240,53 @@ function buildTextSizeExpression(text) {
197
240
  ["get", "textSizePixels"],
198
241
  14
199
242
  ];
200
- const sizeAtZoom = (zoom) => {
201
- const groundPx = [
202
- "*",
203
- ["get", "textSizeMeters"],
204
- Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
205
- ];
206
- return [
243
+ const groundPx = [
244
+ "*",
245
+ ["get", "textSizeMeters"],
246
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
247
+ ];
248
+ return [
249
+ "case",
250
+ ["has", "textSizeMeters"],
251
+ [
207
252
  "case",
208
- ["has", "textSizeMeters"],
253
+ ["has", "textMaxSizePixels"],
209
254
  [
210
- "case",
211
- ["has", "textMaxSizePixels"],
255
+ "min",
256
+ ["get", "textMaxSizePixels"],
257
+ groundPx
258
+ ],
259
+ [
260
+ "min",
261
+ maxPx,
212
262
  [
213
- "min",
214
- ["get", "textMaxSizePixels"],
263
+ "max",
264
+ minPx,
215
265
  groundPx
216
- ],
217
- [
218
- "min",
219
- maxPx,
220
- [
221
- "max",
222
- minPx,
223
- groundPx
224
- ]
225
266
  ]
226
- ],
227
- screenOrDefault
228
- ];
229
- };
267
+ ]
268
+ ],
269
+ screenOrDefault
270
+ ];
271
+ }
272
+ /**
273
+ * Wrap a per-zoom expression builder in the top-level zoom `interpolate` every
274
+ * zoom-driven property here needs. MapLibre only accepts `["zoom"]` as the
275
+ * input of a top-level `step`/`interpolate`, never nested inside a `case`, so
276
+ * the ramp must be outermost and the per-feature branching evaluated at each
277
+ * stop — not the other way round.
278
+ */
279
+ function interpolateOverZoom(at) {
230
280
  return [
231
281
  "interpolate",
232
282
  ["linear"],
233
283
  ["zoom"],
234
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
284
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, at(zoom)]).flat()
235
285
  ];
236
286
  }
287
+ function buildTextSizeExpression(text) {
288
+ return interpolateOverZoom((zoom) => textSizeAtZoom(text, zoom));
289
+ }
237
290
  /**
238
291
  * Companion to `buildTextSizeExpression` (ADR-0037): drives `text-opacity` to
239
292
  * 0 once a ground-anchored Text graphic's derived on-screen size exceeds its
@@ -398,33 +451,72 @@ function buildTextHaloColorExpression(text) {
398
451
  * `resolveTextHaloWidth`'s own outward-depth semantics, so the `textHalo`
399
452
  * case uses its formula directly (unlike OpenLayers's straddling canvas
400
453
  * stroke, which must double it).
454
+ *
455
+ * The width is derived from `textSizeAtZoom` — the same size `text-size`
456
+ * renders at — not from `textSizePixels` alone. A ground-anchored label
457
+ * (ADR-0028) carries only `textSizeMeters`, so reading the pixel size directly
458
+ * would fall back to a constant and pin the halo at one width while the glyphs
459
+ * grew and shrank with zoom: far too heavy on small text, hairline on large.
460
+ * OL and Leaflet already halo off their resolved size; this brings MapLibre in
461
+ * line. The ratio stays well under MapLibre's SDF halo ceiling (~¼ em), so the
462
+ * halo never clips into blocky corners.
401
463
  */
402
464
  function buildTextHaloWidthExpression(text) {
403
- return [
465
+ const fallback = text.strokeColor ? text.strokeWidth ?? 1 : [
466
+ "case",
467
+ HAS_TEXT_BACKGROUND,
468
+ 3,
469
+ 0
470
+ ];
471
+ return interpolateOverZoom((zoom) => [
404
472
  "case",
405
473
  HAS_TEXT_HALO,
474
+ haloWidthAtZoom(text, zoom),
475
+ fallback
476
+ ]);
477
+ }
478
+ /**
479
+ * `text-halo-blur` as a fraction of the halo width. MapLibre's halo edge is
480
+ * hard at blur 0, which reads harsher over a vector basemap than the basemap's
481
+ * own labels — OpenFreeMap Liberty, the preview's default style, blurs nearly
482
+ * every one of its halos. Scaled off the width rather than a flat pixel value
483
+ * so the softness stays proportional as the halo grows with zoom.
484
+ *
485
+ * MapLibre-only: OpenLayers canvas strokes have no blur, and Leaflet's stacked
486
+ * `text-shadow` halo would need a blur radius threaded through each shadow.
487
+ * The three engines' halos therefore differ slightly at the edge.
488
+ */
489
+ const TEXT_HALO_BLUR_RATIO = .25;
490
+ /** The halo's outward depth at one zoom stop; shared by the width and blur builders. */
491
+ function haloWidthAtZoom(text, zoom) {
492
+ return [
493
+ "max",
494
+ 1.5,
406
495
  [
407
- "max",
408
- 1.5,
409
- [
410
- "/",
411
- [
412
- "coalesce",
413
- ["get", "textSizePixels"],
414
- 24
415
- ],
416
- 8
417
- ]
418
- ],
419
- text.strokeColor ? text.strokeWidth ?? 1 : [
420
- "case",
421
- HAS_TEXT_BACKGROUND,
422
- 3,
423
- 0
496
+ "/",
497
+ textSizeAtZoom(text, zoom),
498
+ 8
424
499
  ]
425
500
  ];
426
501
  }
427
502
  /**
503
+ * Per-feature `text-halo-blur`, softening only a `textHalo` halo's edge.
504
+ * Everything else stays at 0 so the `textBackground` backdrop and a
505
+ * layer-level text stroke keep the crisp edge they render today.
506
+ */
507
+ function buildTextHaloBlurExpression(text) {
508
+ return interpolateOverZoom((zoom) => [
509
+ "case",
510
+ HAS_TEXT_HALO,
511
+ [
512
+ "*",
513
+ TEXT_HALO_BLUR_RATIO,
514
+ haloWidthAtZoom(text, zoom)
515
+ ],
516
+ 0
517
+ ]);
518
+ }
519
+ /**
428
520
  * Build the layout and paint objects for a symbol (text) layer.
429
521
  * Centralises the rotation convention so `createLayer` and `setLayerStyle`
430
522
  * stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
@@ -525,6 +617,7 @@ function buildSymbolLayer(symbolId, sourceId, text) {
525
617
  "text-color": textColor,
526
618
  "text-halo-color": buildTextHaloColorExpression(text),
527
619
  "text-halo-width": buildTextHaloWidthExpression(text),
620
+ "text-halo-blur": buildTextHaloBlurExpression(text),
528
621
  "text-opacity": TEXT_OPACITY_EXPRESSION
529
622
  }
530
623
  };
@@ -831,6 +924,8 @@ var MapLibreAdapter = class extends BaseMapAdapter {
831
924
  sourceId,
832
925
  layerIds: /* @__PURE__ */ new Map(),
833
926
  features: [],
927
+ sourceFeatures: [],
928
+ sourceInitialized: false,
834
929
  featureSignature: "[]",
835
930
  requiredIconIds: /* @__PURE__ */ new Set()
836
931
  };
@@ -924,10 +1019,12 @@ var MapLibreAdapter = class extends BaseMapAdapter {
924
1019
  const source = this.map.getSource(bundle.sourceId);
925
1020
  if (!source) return;
926
1021
  const features = [...bundle.requiredIconIds].every((id) => this.map.hasImage(id)) ? bundle.features.map((feature) => this.promoteFeature(feature)) : [];
927
- const result = source.setData({
1022
+ const result = bundle.sourceInitialized && typeof source.updateData === "function" ? source.updateData(buildGeoJSONSourceDiff(bundle.sourceFeatures, features), true) : source.setData({
928
1023
  type: "FeatureCollection",
929
1024
  features
930
1025
  }, true);
1026
+ bundle.sourceFeatures = features;
1027
+ bundle.sourceInitialized = true;
931
1028
  if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
932
1029
  }
933
1030
  orderGroupBundles(handle, previousGroupIds, desiredGroupIds, forcedGroupIds) {
@@ -1220,7 +1317,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1220
1317
  if (styleUpdate.text.text || styleUpdate.text.labelProperty) this.map.setLayoutProperty(layerId, "text-field", buildTextFieldExpression(resolvedStyle.text));
1221
1318
  if (styleUpdate.text.fillColor) this.map.setPaintProperty(layerId, "text-color", resolvedStyle.text.fillColor);
1222
1319
  if (styleUpdate.text.strokeColor) this.map.setPaintProperty(layerId, "text-halo-color", buildTextHaloColorExpression(resolvedStyle.text));
1223
- if (styleUpdate.text.strokeWidth !== void 0) this.map.setPaintProperty(layerId, "text-halo-width", buildTextHaloWidthExpression(resolvedStyle.text));
1320
+ if (styleUpdate.text.strokeWidth !== void 0) {
1321
+ this.map.setPaintProperty(layerId, "text-halo-width", buildTextHaloWidthExpression(resolvedStyle.text));
1322
+ this.map.setPaintProperty(layerId, "text-halo-blur", buildTextHaloBlurExpression(resolvedStyle.text));
1323
+ }
1224
1324
  break;
1225
1325
  }
1226
1326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw-adapter-maplibre",
3
- "version": "0.2.0-alpha.22",
3
+ "version": "0.2.0-alpha.24",
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",
@@ -37,8 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@orbat-mapper/control-measures": "^0.2.0-alpha.26",
41
- "@orbat-mapper/tactical-draw": "^0.2.0-alpha.38"
40
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.28",
41
+ "@orbat-mapper/tactical-draw": "^0.2.0-alpha.39"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "maplibre-gl": ">=5.8.0 <6.0.0"