@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.8 → 0.2.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
@@ -1,37 +1,101 @@
1
- import { BaseMapAdapter, EditPointerDriver, FeatureOps, IconResource, LayerId, MapCursor, NativeEventEntry, PickHandler, PickOptions, PixelCoordinate, StyleOptions } from "@orbat-mapper/tactical-draw";
1
+ import { BaseMapAdapter, EditPointerDriver, FeatureGroup, IconResource, LayerId, MapCursor, NativeEventEntry, PickHandler, PickOptions, PixelCoordinate, StyleOptions } from "@orbat-mapper/tactical-draw";
2
2
  import maplibregl from "maplibre-gl";
3
- import { Position } from "geojson";
3
+ import { Feature, Position } from "geojson";
4
4
 
5
5
  //#region src/ml-adapter.d.ts
6
6
  interface MapLibreLayerHandle {
7
7
  sourceId: string;
8
8
  /** MapLibre layer ids created for this logical layer (fill, line, circle, symbol) */
9
9
  mlLayerIds: string[];
10
+ groupBundles: Map<string, MapLibreGroupBundle>;
11
+ groupOrder: string[];
12
+ nextGroupBundleId: number;
10
13
  style?: StyleOptions;
11
14
  }
15
+ type PaintChannel = "fill" | "fill-pattern" | "line" | "circle" | "icon" | "text";
16
+ interface MapLibreGroupBundle {
17
+ readonly sourceId: string;
18
+ readonly layerIds: Map<PaintChannel, string>;
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;
24
+ /** Unset while the stored value describes older features than `features` holds. */
25
+ featureSignature: string | undefined;
26
+ /**
27
+ * Last `FeatureGroup.revision` seen for this bundle, when the producer
28
+ * supplied one. Two consecutive groups with the same `revision` are known
29
+ * to carry byte-identical `features`, so it stands in for `featureSignature`
30
+ * without paying to re-stringify the whole group. A missing revision on
31
+ * either side falls back to the signature comparison — see `applyFeatureGroups`.
32
+ */
33
+ revision?: number;
34
+ requiredIconIds: ReadonlySet<string>;
35
+ }
12
36
  type MapLibreEventToken = (e: any) => void;
13
37
  interface MapLibreViewToken {
14
38
  fn: () => void;
15
39
  }
40
+ interface MapLibreAdapterOptions {
41
+ /**
42
+ * How view changes propagate to view-state refits. "continuous" (default)
43
+ * refits on every frame of an animated zoom/rotate/pitch; "settle" defers
44
+ * the refit until the animation ends.
45
+ */
46
+ viewChangeMode?: "continuous" | "settle";
47
+ }
16
48
  declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLibreEventToken, MapLibreViewToken> {
17
49
  private map;
50
+ private readonly viewChangeMode;
18
51
  private readonly pendingIconImages;
19
- constructor(map: maplibregl.Map);
52
+ readonly scalesGroundPointSymbolsNatively = true;
53
+ constructor(map: maplibregl.Map, options?: MapLibreAdapterOptions);
20
54
  private toContinuousLng;
21
55
  protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
22
56
  protected destroyLayer(handle: MapLibreLayerHandle): void;
23
- protected applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
57
+ protected applyFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
58
+ private createGroupBundle;
59
+ private destroyGroupBundle;
60
+ private clearGroupBundles;
61
+ private requiredIconIds;
62
+ private reconcileGroupChannels;
63
+ private buildGroupLayer;
64
+ private updateGroupBundleSource;
65
+ private orderGroupBundles;
66
+ private nativeLayerIds;
67
+ /**
68
+ * Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
69
+ * and picks can key on the caller's feature id) and, for a hatch-patterned
70
+ * Polygon, `_fillPatternImageId` (registering the tile image on demand). At
71
+ * most one object spread — a feature needing neither passes through
72
+ * untouched.
73
+ */
74
+ private promoteFeature;
75
+ /**
76
+ * Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
77
+ * hatch-tile image id, registering the image on demand. Returns `undefined`
78
+ * for features without a hatch pattern, so `fillPatternImageId` stays the
79
+ * single source of truth for the id baked into `_fillPatternImageId`.
80
+ */
81
+ private resolveFillPatternImage;
82
+ /** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
83
+ private ensureFillPatternImage;
24
84
  protected applyIconResources(resources: readonly IconResource[]): void;
25
85
  protected removeIconResource(id: string): void;
26
86
  protected attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void): NativeEventEntry<MapLibreEventToken>;
27
87
  protected detachNativeEvent(entry: NativeEventEntry<MapLibreEventToken>): void;
28
88
  protected attachViewChange(handler: () => void): MapLibreViewToken;
29
89
  protected detachViewChange(token: MapLibreViewToken): void;
90
+ private viewChangeEvents;
30
91
  createEditPointerDriver(): EditPointerDriver;
31
92
  setLayerStyle(layerId: LayerId, style: StyleOptions): void;
93
+ private legacyPaintChannel;
94
+ private applyChannelStyle;
32
95
  toLonLat(coordinate: number[]): Position;
33
96
  fromLonLat(position: Position): number[];
34
97
  getPixelFromCoordinate(position: Position): PixelCoordinate | null;
98
+ getCoordinateFromPixel(pixel: PixelCoordinate): Position | null;
35
99
  getViewportSize(): {
36
100
  width: number;
37
101
  height: number;
@@ -48,4 +112,4 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
48
112
  setGlobeMode(enabled: boolean): void;
49
113
  }
50
114
  //#endregion
51
- export { MapLibreAdapter };
115
+ export { MapLibreAdapter, type MapLibreAdapterOptions };
package/dist/index.mjs CHANGED
@@ -1,6 +1,115 @@
1
1
  import { project, unproject } from "@orbat-mapper/control-measures";
2
- import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, bindAbortable, nearestWorldCopyLng, pickHitTolerance, tryControlMeasureIdFromFeature } from "@orbat-mapper/tactical-draw";
2
+ import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, bindAbortable, evaluatePickCandidates, fillPatternImageId, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, pickHitTolerance, pickPointerType } from "@orbat-mapper/tactical-draw";
3
3
  //#region src/ml-adapter.ts
4
+ /** Feature property key for the resolved hatch-pattern image id (see `promoteFeature`). */
5
+ const FILL_PATTERN_IMAGE_ID_PROPERTY = "_fillPatternImageId";
6
+ const DEFAULT_LINE_COLOR = "#000";
7
+ const DEFAULT_LINE_WIDTH = 2;
8
+ function isHatchPattern(value) {
9
+ return HATCH_PATTERNS.includes(value);
10
+ }
11
+ const PAINT_CHANNELS = [
12
+ "fill",
13
+ "fill-pattern",
14
+ "line",
15
+ "circle",
16
+ "icon",
17
+ "text"
18
+ ];
19
+ function firstBundleLayerId(bundle) {
20
+ for (const channel of PAINT_CHANNELS) {
21
+ const layerId = bundle.layerIds.get(channel);
22
+ if (layerId) return layerId;
23
+ }
24
+ }
25
+ function sameOrder(a, b) {
26
+ if (a.length !== b.length) return false;
27
+ for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
28
+ return true;
29
+ }
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
+ /**
74
+ * Find the largest subset of bundles whose relative order already matches the
75
+ * desired stack. Reorders then move only the remaining bundles.
76
+ */
77
+ function longestStableGroupIds(previousGroupIds, desiredGroupIds) {
78
+ const previousIndices = new Map(previousGroupIds.map((groupId, index) => [groupId, index]));
79
+ const sequence = desiredGroupIds.flatMap((groupId) => {
80
+ const previousIndex = previousIndices.get(groupId);
81
+ return previousIndex === void 0 ? [] : [{
82
+ groupId,
83
+ previousIndex
84
+ }];
85
+ });
86
+ const tails = [];
87
+ const tailSequenceIndices = [];
88
+ const predecessors = new Array(sequence.length).fill(-1);
89
+ for (let sequenceIndex = 0; sequenceIndex < sequence.length; sequenceIndex++) {
90
+ const previousIndex = sequence[sequenceIndex].previousIndex;
91
+ let low = 0;
92
+ let high = tails.length;
93
+ while (low < high) {
94
+ const middle = low + high >>> 1;
95
+ if (tails[middle] < previousIndex) low = middle + 1;
96
+ else high = middle;
97
+ }
98
+ if (low > 0) predecessors[sequenceIndex] = tailSequenceIndices[low - 1];
99
+ tails[low] = previousIndex;
100
+ tailSequenceIndices[low] = sequenceIndex;
101
+ }
102
+ const stableGroupIds = /* @__PURE__ */ new Set();
103
+ let sequenceIndex = tailSequenceIndices.at(-1);
104
+ while (sequenceIndex !== void 0 && sequenceIndex >= 0) {
105
+ stableGroupIds.add(sequence[sequenceIndex].groupId);
106
+ sequenceIndex = predecessors[sequenceIndex];
107
+ }
108
+ return stableGroupIds;
109
+ }
110
+ function isPromiseLike(value) {
111
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
112
+ }
4
113
  function emptyFC() {
5
114
  return {
6
115
  type: "FeatureCollection",
@@ -80,11 +189,7 @@ function buildIconLayer(id, source) {
80
189
  ],
81
190
  layout: {
82
191
  "icon-image": ["get", "iconId"],
83
- "icon-size": [
84
- "coalesce",
85
- ["get", "iconScale"],
86
- 1
87
- ],
192
+ "icon-size": buildIconSizeExpression(),
88
193
  "icon-rotate": [
89
194
  "*",
90
195
  [
@@ -97,7 +202,8 @@ function buildIconLayer(id, source) {
97
202
  "icon-rotation-alignment": "viewport",
98
203
  "icon-pitch-alignment": "viewport",
99
204
  "icon-allow-overlap": true,
100
- "icon-ignore-placement": true
205
+ "icon-ignore-placement": true,
206
+ "symbol-z-order": "source"
101
207
  },
102
208
  paint: { "icon-opacity": [
103
209
  "coalesce",
@@ -126,7 +232,7 @@ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
126
232
  * matching `getResolution`); the linear ramp between stops approximates the
127
233
  * exponential exactly closely enough at 1-zoom granularity.
128
234
  */
129
- function buildTextSizeExpression(text) {
235
+ function textSizeAtZoom(text, zoom) {
130
236
  const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
131
237
  const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
132
238
  const screenOrDefault = [
@@ -134,31 +240,149 @@ function buildTextSizeExpression(text) {
134
240
  ["get", "textSizePixels"],
135
241
  14
136
242
  ];
137
- const sizeAtZoom = (zoom) => {
138
- 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
+ [
139
252
  "case",
140
- ["has", "textSizeMeters"],
253
+ ["has", "textMaxSizePixels"],
254
+ [
255
+ "min",
256
+ ["get", "textMaxSizePixels"],
257
+ groundPx
258
+ ],
141
259
  [
142
260
  "min",
143
261
  maxPx,
144
262
  [
145
263
  "max",
146
264
  minPx,
265
+ groundPx
266
+ ]
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) {
280
+ return [
281
+ "interpolate",
282
+ ["linear"],
283
+ ["zoom"],
284
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, at(zoom)]).flat()
285
+ ];
286
+ }
287
+ function buildTextSizeExpression(text) {
288
+ return interpolateOverZoom((zoom) => textSizeAtZoom(text, zoom));
289
+ }
290
+ /**
291
+ * Companion to `buildTextSizeExpression` (ADR-0037): drives `text-opacity` to
292
+ * 0 once a ground-anchored Text graphic's derived on-screen size exceeds its
293
+ * `textMaxSizePixels` threshold, 1 otherwise (including every feature without
294
+ * a threshold, e.g. amplifier labels). Layout properties like `text-size`
295
+ * cannot represent "hidden", so hiding is a paint-property concern; `step`
296
+ * (rather than `interpolate`) is enough since opacity only ever needs to
297
+ * flip at a stop, evaluated at the same per-zoom granularity as the size
298
+ * expression.
299
+ */
300
+ const TEXT_OPACITY_EXPRESSION = (() => {
301
+ const opacityAtZoom = (zoom) => {
302
+ return [
303
+ "case",
304
+ [
305
+ "all",
306
+ ["has", "textSizeMeters"],
307
+ ["has", "textMaxSizePixels"]
308
+ ],
309
+ [
310
+ "case",
311
+ [
312
+ ">",
147
313
  [
148
314
  "*",
149
315
  ["get", "textSizeMeters"],
150
316
  Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
317
+ ],
318
+ ["get", "textMaxSizePixels"]
319
+ ],
320
+ 0,
321
+ 1
322
+ ],
323
+ 1
324
+ ];
325
+ };
326
+ const stops = Array.from({ length: 25 }, (_, zoom) => zoom);
327
+ return [
328
+ "step",
329
+ ["zoom"],
330
+ opacityAtZoom(0),
331
+ ...stops.slice(1).flatMap((zoom) => [zoom, opacityAtZoom(zoom)])
332
+ ];
333
+ })();
334
+ /**
335
+ * Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
336
+ * a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
337
+ * optional `iconMinPixels`/`iconMaxPixels`), so the meters→pixels clamp is
338
+ * evaluated inside the style layer at every integer zoom stop and the symbol
339
+ * scales smoothly through animated zooms. Anything else falls back to the
340
+ * host-computed `iconScale` snapshot.
341
+ */
342
+ function buildIconSizeExpression() {
343
+ const minExpr = [
344
+ "coalesce",
345
+ ["get", "iconMinPixels"],
346
+ 0
347
+ ];
348
+ const maxExpr = [
349
+ "coalesce",
350
+ ["get", "iconMaxPixels"],
351
+ 1e9
352
+ ];
353
+ const scaleAtZoom = (zoom) => {
354
+ return [
355
+ "case",
356
+ ["has", "iconSizeMeters"],
357
+ [
358
+ "/",
359
+ [
360
+ "max",
361
+ minExpr,
362
+ [
363
+ "min",
364
+ maxExpr,
365
+ [
366
+ "*",
367
+ ["get", "iconSizeMeters"],
368
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
369
+ ]
151
370
  ]
152
- ]
371
+ ],
372
+ ["get", "iconOctagonSize"]
153
373
  ],
154
- screenOrDefault
374
+ [
375
+ "coalesce",
376
+ ["get", "iconScale"],
377
+ 1
378
+ ]
155
379
  ];
156
380
  };
157
381
  return [
158
382
  "interpolate",
159
383
  ["linear"],
160
384
  ["zoom"],
161
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
385
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, scaleAtZoom(zoom)]).flat()
162
386
  ];
163
387
  }
164
388
  /**
@@ -166,6 +390,133 @@ function buildTextSizeExpression(text) {
166
390
  * `properties.style.fillColor` overlay. Mirrors `buildCirclePaint`.
167
391
  */
168
392
  /**
393
+ * Per-feature `text-field` value: the layer's literal text, or the feature's
394
+ * `labelProperty` value, uppercased when the feature's `textStyle` is
395
+ * `"caps"`. Factored out of `buildSymbolLayer` so `setLayerStyle`'s
396
+ * incremental `text-field` update (below) can reuse it instead of
397
+ * regressing to a caps-unaware plain `["get", ...]`.
398
+ */
399
+ function buildTextFieldExpression(text) {
400
+ const rawTextField = text.labelProperty ? ["get", text.labelProperty] : text.text ?? "";
401
+ return text.labelProperty ? [
402
+ "case",
403
+ [
404
+ "==",
405
+ ["get", "textStyle"],
406
+ "caps"
407
+ ],
408
+ ["upcase", rawTextField],
409
+ rawTextField
410
+ ] : rawTextField;
411
+ }
412
+ const HAS_TEXT_HALO = [
413
+ "boolean",
414
+ ["get", "textHalo"],
415
+ false
416
+ ];
417
+ const HAS_TEXT_BACKGROUND = [
418
+ "boolean",
419
+ ["get", "textBackground"],
420
+ false
421
+ ];
422
+ /**
423
+ * Per-feature `text-halo-color` value: `textHalo` wins over both the layer's
424
+ * `text.strokeColor` and the `textBackground` halo, matching the OL/Leaflet
425
+ * adapters' precedence. Factored out so `setLayerStyle`'s incremental
426
+ * `text-halo-color` update (below) can reuse it instead of dropping the
427
+ * `textHalo` case.
428
+ */
429
+ function buildTextHaloColorExpression(text) {
430
+ const fallback = text.strokeColor ? text.strokeColor : [
431
+ "case",
432
+ HAS_TEXT_BACKGROUND,
433
+ "#ffffff",
434
+ "rgba(255,255,255,0)"
435
+ ];
436
+ return [
437
+ "case",
438
+ HAS_TEXT_HALO,
439
+ [
440
+ "coalesce",
441
+ ["get", "textHaloColor"],
442
+ TEXT_HALO_COLOR
443
+ ],
444
+ fallback
445
+ ];
446
+ }
447
+ /**
448
+ * Per-feature `text-halo-width` value, mirroring
449
+ * `buildTextHaloColorExpression`'s precedence. MapLibre's `text-halo-width`
450
+ * already extends outward from the glyph edge, matching
451
+ * `resolveTextHaloWidth`'s own outward-depth semantics, so the `textHalo`
452
+ * case uses its formula directly (unlike OpenLayers's straddling canvas
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.
463
+ */
464
+ function buildTextHaloWidthExpression(text) {
465
+ const fallback = text.strokeColor ? text.strokeWidth ?? 1 : [
466
+ "case",
467
+ HAS_TEXT_BACKGROUND,
468
+ 3,
469
+ 0
470
+ ];
471
+ return interpolateOverZoom((zoom) => [
472
+ "case",
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,
495
+ [
496
+ "/",
497
+ textSizeAtZoom(text, zoom),
498
+ 8
499
+ ]
500
+ ];
501
+ }
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
+ /**
169
520
  * Build the layout and paint objects for a symbol (text) layer.
170
521
  * Centralises the rotation convention so `createLayer` and `setLayerStyle`
171
522
  * stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
@@ -214,26 +565,60 @@ function buildSymbolLayer(symbolId, sourceId, text) {
214
565
  "right",
215
566
  "center"
216
567
  ] : "center";
568
+ const textJustify = text.labelProperty ? [
569
+ "case",
570
+ [
571
+ "==",
572
+ ["get", "textJustify"],
573
+ "left"
574
+ ],
575
+ "left",
576
+ [
577
+ "==",
578
+ ["get", "textJustify"],
579
+ "right"
580
+ ],
581
+ "right",
582
+ "center"
583
+ ] : "center";
217
584
  return {
218
585
  id: symbolId,
219
586
  type: "symbol",
220
587
  source: sourceId,
221
588
  layout: {
222
- "text-field": text.labelProperty ? ["get", text.labelProperty] : text.text ?? "",
223
- "text-font": ["Open Sans Regular"],
589
+ "text-field": buildTextFieldExpression(text),
590
+ "text-font": text.labelProperty ? [
591
+ "case",
592
+ [
593
+ "==",
594
+ ["get", "textStyle"],
595
+ "italic"
596
+ ],
597
+ ["literal", ["Open Sans Italic"]],
598
+ [
599
+ "==",
600
+ ["get", "textStyle"],
601
+ "light"
602
+ ],
603
+ ["literal", ["Open Sans Light"]],
604
+ ["literal", ["Open Sans Regular"]]
605
+ ] : ["Open Sans Regular"],
224
606
  "text-size": buildTextSizeExpression(text),
225
607
  "text-rotate": textRotate,
226
608
  "text-anchor": textAnchor,
609
+ "text-justify": textJustify,
610
+ "text-max-width": 9999,
227
611
  "text-rotation-alignment": "map",
228
612
  "text-pitch-alignment": "map",
229
- "text-allow-overlap": true
613
+ "text-allow-overlap": true,
614
+ "symbol-z-order": "source"
230
615
  },
231
616
  paint: {
232
617
  "text-color": textColor,
233
- ...text.strokeColor ? {
234
- "text-halo-color": text.strokeColor,
235
- "text-halo-width": text.strokeWidth ?? 1
236
- } : {}
618
+ "text-halo-color": buildTextHaloColorExpression(text),
619
+ "text-halo-width": buildTextHaloWidthExpression(text),
620
+ "text-halo-blur": buildTextHaloBlurExpression(text),
621
+ "text-opacity": TEXT_OPACITY_EXPRESSION
237
622
  }
238
623
  };
239
624
  }
@@ -255,14 +640,79 @@ function buildFillPaint(style) {
255
640
  "fill-opacity": 1
256
641
  };
257
642
  }
643
+ /**
644
+ * `properties.style.fillPattern` read as a GL expression, defaulting to
645
+ * `"solid"`. `fillPattern` is a per-feature-only field (ADR-0007) — unlike
646
+ * `fillColor`/`strokeColor` it has no layer-level `StyleOptions` default, so
647
+ * this expression takes no `style` argument.
648
+ */
649
+ function fillPatternField() {
650
+ return [
651
+ "coalesce",
652
+ [
653
+ "get",
654
+ "fillPattern",
655
+ [
656
+ "coalesce",
657
+ ["get", "style"],
658
+ ["literal", {}]
659
+ ]
660
+ ],
661
+ "solid"
662
+ ];
663
+ }
664
+ /**
665
+ * Filter for one of the two mutually-exclusive Polygon fill layers, keyed on
666
+ * whether the feature's resolved `fillPattern` is a hatch pattern. The solid
667
+ * `-fill` layer takes `hatched: false` (every Polygon except hatched ones);
668
+ * the `-fill-pattern` layer takes `hatched: true`. Split because MapLibre's
669
+ * `fill-color` and `fill-pattern` paints cannot coexist on one fill layer.
670
+ */
671
+ function buildFillFilter(hatched) {
672
+ const isHatched = [
673
+ "in",
674
+ fillPatternField(),
675
+ ["literal", [...HATCH_PATTERNS]]
676
+ ];
677
+ return [
678
+ "all",
679
+ [
680
+ "==",
681
+ ["geometry-type"],
682
+ "Polygon"
683
+ ],
684
+ hatched ? isHatched : ["!", isHatched]
685
+ ];
686
+ }
687
+ /**
688
+ * Paint for the hatch-pattern fill layer. The image id is baked per-feature
689
+ * into `properties._fillPatternImageId` (see `promoteFeature`), so the paint
690
+ * is a plain `["get", ...]` rather than re-deriving the id as a GL expression,
691
+ * keeping `fillPatternImageId` the single source of truth for the id string.
692
+ */
693
+ const FILL_PATTERN_PAINT = {
694
+ "fill-pattern": ["get", FILL_PATTERN_IMAGE_ID_PROPERTY],
695
+ "fill-opacity": 1
696
+ };
697
+ const SOLID_LINE_DASH = [1, 0];
258
698
  function buildLinePaint(style) {
259
- const colorDefault = style?.strokeColor ?? "#000";
260
- const widthDefault = style?.strokeWidth ?? 2;
699
+ const colorDefault = style?.strokeColor ?? DEFAULT_LINE_COLOR;
700
+ const widthDefault = style?.strokeWidth ?? DEFAULT_LINE_WIDTH;
701
+ const dashDefault = style?.strokeDash?.length ? [...style.strokeDash] : SOLID_LINE_DASH;
261
702
  const styleObj = [
262
703
  "coalesce",
263
704
  ["get", "style"],
264
705
  ["literal", {}]
265
706
  ];
707
+ const dashExpr = [
708
+ "coalesce",
709
+ [
710
+ "get",
711
+ "strokeDash",
712
+ styleObj
713
+ ],
714
+ ["literal", dashDefault]
715
+ ];
266
716
  return {
267
717
  "line-color": [
268
718
  "coalesce",
@@ -282,15 +732,63 @@ function buildLinePaint(style) {
282
732
  ],
283
733
  widthDefault
284
734
  ],
285
- ...style?.strokeDash ? { "line-dasharray": style.strokeDash } : {}
735
+ "line-dasharray": [
736
+ "case",
737
+ [
738
+ ">",
739
+ ["length", dashExpr],
740
+ 0
741
+ ],
742
+ dashExpr,
743
+ ["literal", SOLID_LINE_DASH]
744
+ ]
286
745
  };
287
746
  }
747
+ function geometryIncludes(feature, geometryTypes) {
748
+ const geometry = feature.geometry;
749
+ if (!geometry) return false;
750
+ if (geometry.type === "GeometryCollection") return geometry.geometries.some((candidate) => geometryTypes.has(candidate.type));
751
+ return geometryTypes.has(geometry.type);
752
+ }
753
+ const POLYGON_GEOMETRIES = new Set(["Polygon", "MultiPolygon"]);
754
+ const LINE_GEOMETRIES = new Set([
755
+ "LineString",
756
+ "MultiLineString",
757
+ "Polygon",
758
+ "MultiPolygon"
759
+ ]);
760
+ const POINT_GEOMETRIES = new Set(["Point", "MultiPoint"]);
761
+ function featureStyle(feature) {
762
+ const style = feature.properties?.style;
763
+ return style && typeof style === "object" ? style : {};
764
+ }
765
+ function groupPaintChannels(features, style) {
766
+ const channels = /* @__PURE__ */ new Set();
767
+ for (const feature of features) {
768
+ if (geometryIncludes(feature, POLYGON_GEOMETRIES)) {
769
+ const fillPattern = featureStyle(feature).fillPattern;
770
+ if (featureStyle(feature).fillColor !== void 0 || style?.fillColor !== void 0 || isHatchPattern(fillPattern)) channels.add(isHatchPattern(fillPattern) ? "fill-pattern" : "fill");
771
+ }
772
+ if (geometryIncludes(feature, LINE_GEOMETRIES)) channels.add("line");
773
+ if (!geometryIncludes(feature, POINT_GEOMETRIES)) continue;
774
+ const properties = feature.properties ?? {};
775
+ const hasIcon = properties.iconId != null;
776
+ const hasText = style?.text !== void 0 && (style.text.labelProperty ? properties[style.text.labelProperty] != null : style.text.text !== void 0);
777
+ if (hasIcon) channels.add("icon");
778
+ if (hasText) channels.add("text");
779
+ if (!hasIcon && !hasText) channels.add("circle");
780
+ }
781
+ return channels;
782
+ }
288
783
  var MapLibreAdapter = class extends BaseMapAdapter {
289
784
  map;
785
+ viewChangeMode;
290
786
  pendingIconImages = /* @__PURE__ */ new Set();
291
- constructor(map) {
787
+ scalesGroundPointSymbolsNatively = true;
788
+ constructor(map, options = {}) {
292
789
  super();
293
790
  this.map = map;
791
+ this.viewChangeMode = options.viewChangeMode ?? "continuous";
294
792
  }
295
793
  toContinuousLng(lng) {
296
794
  return nearestWorldCopyLng(lng, this.map.getCenter().lng);
@@ -308,14 +806,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
308
806
  id: fillId,
309
807
  type: "fill",
310
808
  source: sourceId,
311
- filter: [
312
- "==",
313
- "$type",
314
- "Polygon"
315
- ],
809
+ filter: buildFillFilter(false),
316
810
  paint: buildFillPaint(style)
317
811
  });
318
812
  mlLayerIds.push(fillId);
813
+ const fillPatternId = `${layerId}-fill-pattern`;
814
+ this.map.addLayer({
815
+ id: fillPatternId,
816
+ type: "fill",
817
+ source: sourceId,
818
+ filter: buildFillFilter(true),
819
+ paint: FILL_PATTERN_PAINT
820
+ });
821
+ mlLayerIds.push(fillPatternId);
319
822
  const lineId = `${layerId}-line`;
320
823
  this.map.addLayer({
321
824
  id: lineId,
@@ -361,29 +864,228 @@ var MapLibreAdapter = class extends BaseMapAdapter {
361
864
  return {
362
865
  sourceId,
363
866
  mlLayerIds,
867
+ groupBundles: /* @__PURE__ */ new Map(),
868
+ groupOrder: [],
869
+ nextGroupBundleId: 0,
364
870
  style
365
871
  };
366
872
  }
367
873
  destroyLayer(handle) {
874
+ this.clearGroupBundles(handle);
368
875
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
369
876
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
370
877
  }
371
- applyFeatureOps(layerId, ops) {
878
+ applyFeatureGroups(layerId, groups) {
372
879
  const handle = this.layers.get(layerId);
373
880
  if (!handle) return;
374
- const source = this.map.getSource(handle.sourceId);
375
- if (!source) return;
376
- const features = ops.full.map((f) => f.id !== void 0 ? {
377
- ...f,
378
- properties: {
379
- ...f.properties ?? {},
380
- _fid: f.id
881
+ const desiredGroupIds = new Set(groups.map((group) => group.id));
882
+ const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
883
+ const bundlesNeedingOrder = /* @__PURE__ */ new Set();
884
+ for (const [groupId, bundle] of handle.groupBundles) {
885
+ if (desiredGroupIds.has(groupId)) continue;
886
+ this.destroyGroupBundle(bundle);
887
+ handle.groupBundles.delete(groupId);
888
+ }
889
+ for (const group of groups) {
890
+ let bundle = handle.groupBundles.get(group.id);
891
+ const isNewBundle = bundle === void 0;
892
+ if (!bundle) {
893
+ bundle = this.createGroupBundle(layerId, handle);
894
+ handle.groupBundles.set(group.id, bundle);
895
+ }
896
+ let contentChanged;
897
+ if (!isNewBundle && bundle.revision !== void 0 && group.revision !== void 0) {
898
+ contentChanged = bundle.revision !== group.revision;
899
+ if (contentChanged) bundle.featureSignature = void 0;
900
+ } else {
901
+ const featureSignature = JSON.stringify(group.features);
902
+ contentChanged = isNewBundle || bundle.featureSignature !== featureSignature;
903
+ bundle.featureSignature = featureSignature;
904
+ }
905
+ const requiredIconIds = this.requiredIconIds(group);
906
+ const resourcesChanged = bundle.requiredIconIds.size !== requiredIconIds.size || [...bundle.requiredIconIds].some((id) => !requiredIconIds.has(id));
907
+ bundle.features = group.features;
908
+ bundle.revision = group.revision;
909
+ bundle.requiredIconIds = requiredIconIds;
910
+ if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(group.features, handle.style))) bundlesNeedingOrder.add(group.id);
911
+ if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
912
+ }
913
+ handle.groupOrder = groups.map((group) => group.id);
914
+ this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
915
+ }
916
+ createGroupBundle(layerId, handle) {
917
+ const sourceId = `${layerId}-group-${++handle.nextGroupBundleId}-src`;
918
+ this.map.addSource(sourceId, {
919
+ type: "geojson",
920
+ data: emptyFC(),
921
+ promoteId: "_fid"
922
+ });
923
+ return {
924
+ sourceId,
925
+ layerIds: /* @__PURE__ */ new Map(),
926
+ features: [],
927
+ sourceFeatures: [],
928
+ sourceInitialized: false,
929
+ featureSignature: "[]",
930
+ requiredIconIds: /* @__PURE__ */ new Set()
931
+ };
932
+ }
933
+ destroyGroupBundle(bundle) {
934
+ for (const layerId of bundle.layerIds.values()) if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
935
+ if (this.map.getSource(bundle.sourceId)) this.map.removeSource(bundle.sourceId);
936
+ }
937
+ clearGroupBundles(handle) {
938
+ for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
939
+ handle.groupBundles.clear();
940
+ handle.groupOrder = [];
941
+ }
942
+ requiredIconIds(group) {
943
+ const referenced = new Set(group.features.flatMap((feature) => {
944
+ const iconId = feature.properties?.iconId;
945
+ return typeof iconId === "string" ? [iconId] : [];
946
+ }));
947
+ return new Set(group.iconResources.map((resource) => resource.id).filter((resourceId) => referenced.has(resourceId)));
948
+ }
949
+ reconcileGroupChannels(handle, bundle, desiredChannels) {
950
+ let changed = false;
951
+ for (const channel of PAINT_CHANNELS) {
952
+ const layerId = bundle.layerIds.get(channel);
953
+ if (layerId && !desiredChannels.has(channel)) {
954
+ if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
955
+ bundle.layerIds.delete(channel);
956
+ changed = true;
957
+ } else if (!layerId && desiredChannels.has(channel)) {
958
+ const nextLayerId = `${bundle.sourceId}-${channel}`;
959
+ this.map.addLayer(this.buildGroupLayer(nextLayerId, bundle.sourceId, channel, handle.style), handle.mlLayerIds[0]);
960
+ bundle.layerIds.set(channel, nextLayerId);
961
+ changed = true;
381
962
  }
382
- } : f);
383
- source.setData({
963
+ }
964
+ return changed;
965
+ }
966
+ buildGroupLayer(id, sourceId, channel, style) {
967
+ switch (channel) {
968
+ case "fill": return {
969
+ id,
970
+ type: "fill",
971
+ source: sourceId,
972
+ filter: buildFillFilter(false),
973
+ paint: buildFillPaint(style)
974
+ };
975
+ case "fill-pattern": return {
976
+ id,
977
+ type: "fill",
978
+ source: sourceId,
979
+ filter: buildFillFilter(true),
980
+ paint: FILL_PATTERN_PAINT
981
+ };
982
+ case "line": return {
983
+ id,
984
+ type: "line",
985
+ source: sourceId,
986
+ filter: [
987
+ "any",
988
+ [
989
+ "==",
990
+ "$type",
991
+ "LineString"
992
+ ],
993
+ [
994
+ "==",
995
+ "$type",
996
+ "Polygon"
997
+ ]
998
+ ],
999
+ paint: buildLinePaint(style),
1000
+ layout: {
1001
+ "line-cap": "round",
1002
+ "line-join": "round"
1003
+ }
1004
+ };
1005
+ case "circle": return {
1006
+ id,
1007
+ type: "circle",
1008
+ source: sourceId,
1009
+ filter: buildCirclePointFilter(style),
1010
+ paint: buildCirclePaint(style)
1011
+ };
1012
+ case "icon": return buildIconLayer(id, sourceId);
1013
+ case "text":
1014
+ if (!style?.text) throw new Error("Cannot create a MapLibre text channel without text style");
1015
+ return buildSymbolLayer(id, sourceId, style.text);
1016
+ }
1017
+ }
1018
+ updateGroupBundleSource(bundle) {
1019
+ const source = this.map.getSource(bundle.sourceId);
1020
+ if (!source) return;
1021
+ const features = [...bundle.requiredIconIds].every((id) => this.map.hasImage(id)) ? bundle.features.map((feature) => this.promoteFeature(feature)) : [];
1022
+ const result = bundle.sourceInitialized && typeof source.updateData === "function" ? source.updateData(buildGeoJSONSourceDiff(bundle.sourceFeatures, features), true) : source.setData({
384
1023
  type: "FeatureCollection",
385
1024
  features
386
- });
1025
+ }, true);
1026
+ bundle.sourceFeatures = features;
1027
+ bundle.sourceInitialized = true;
1028
+ if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
1029
+ }
1030
+ orderGroupBundles(handle, previousGroupIds, desiredGroupIds, forcedGroupIds) {
1031
+ let beforeId = handle.mlLayerIds[0];
1032
+ if (!beforeId) return;
1033
+ const stableGroupIds = sameOrder(previousGroupIds, desiredGroupIds) ? null : longestStableGroupIds(previousGroupIds, desiredGroupIds);
1034
+ for (let index = desiredGroupIds.length - 1; index >= 0; index--) {
1035
+ const groupId = desiredGroupIds[index];
1036
+ const bundle = handle.groupBundles.get(groupId);
1037
+ if (!bundle) continue;
1038
+ if (forcedGroupIds.has(groupId) || stableGroupIds !== null && !stableGroupIds.has(groupId)) for (const channel of PAINT_CHANNELS) {
1039
+ const layerId = bundle.layerIds.get(channel);
1040
+ if (layerId && this.map.getLayer(layerId)) this.map.moveLayer(layerId, beforeId);
1041
+ }
1042
+ beforeId = firstBundleLayerId(bundle) ?? beforeId;
1043
+ }
1044
+ }
1045
+ nativeLayerIds(handle) {
1046
+ return [...handle.mlLayerIds, ...[...handle.groupBundles.values()].flatMap((bundle) => [...bundle.layerIds.values()])];
1047
+ }
1048
+ /**
1049
+ * Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
1050
+ * and picks can key on the caller's feature id) and, for a hatch-patterned
1051
+ * Polygon, `_fillPatternImageId` (registering the tile image on demand). At
1052
+ * most one object spread — a feature needing neither passes through
1053
+ * untouched.
1054
+ */
1055
+ promoteFeature(feature) {
1056
+ const extra = {};
1057
+ if (feature.id !== void 0) extra._fid = feature.id;
1058
+ const imageId = this.resolveFillPatternImage(feature);
1059
+ if (imageId !== void 0) extra[FILL_PATTERN_IMAGE_ID_PROPERTY] = imageId;
1060
+ if (Object.keys(extra).length === 0) return feature;
1061
+ return {
1062
+ ...feature,
1063
+ properties: {
1064
+ ...feature.properties ?? {},
1065
+ ...extra
1066
+ }
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
1071
+ * hatch-tile image id, registering the image on demand. Returns `undefined`
1072
+ * for features without a hatch pattern, so `fillPatternImageId` stays the
1073
+ * single source of truth for the id baked into `_fillPatternImageId`.
1074
+ */
1075
+ resolveFillPatternImage(feature) {
1076
+ const props = feature.properties;
1077
+ const pattern = props?.style?.fillPattern;
1078
+ if (!isHatchPattern(pattern)) return void 0;
1079
+ const color = props?.fillColor ?? props?.style?.fillColor ?? "rgba(0,0,0,0)";
1080
+ const imageId = fillPatternImageId(color, pattern);
1081
+ this.ensureFillPatternImage(imageId, color, pattern);
1082
+ return imageId;
1083
+ }
1084
+ /** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
1085
+ ensureFillPatternImage(imageId, color, pattern) {
1086
+ if (this.map.hasImage(imageId)) return;
1087
+ const image = makeFillPatternImage(color, pattern, 2);
1088
+ this.map.addImage(imageId, image, { pixelRatio: 2 });
387
1089
  }
388
1090
  applyIconResources(resources) {
389
1091
  if (typeof Image === "undefined") return;
@@ -395,6 +1097,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
395
1097
  if (!this.pendingIconImages.has(resource.id)) return;
396
1098
  this.pendingIconImages.delete(resource.id);
397
1099
  if (!this.map.hasImage(resource.id)) this.map.addImage(resource.id, image);
1100
+ for (const handle of this.layers.values()) for (const bundle of handle.groupBundles.values()) if (bundle.requiredIconIds.has(resource.id)) this.updateGroupBundleSource(bundle);
398
1101
  };
399
1102
  image.onerror = () => this.pendingIconImages.delete(resource.id);
400
1103
  image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
@@ -420,17 +1123,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
420
1123
  }
421
1124
  attachViewChange(handler) {
422
1125
  const fn = () => handler();
423
- this.map.on("zoom", fn);
424
- this.map.on("rotate", fn);
425
- this.map.on("pitch", fn);
426
- this.map.on("moveend", fn);
1126
+ for (const event of this.viewChangeEvents()) this.map.on(event, fn);
427
1127
  return { fn };
428
1128
  }
429
1129
  detachViewChange(token) {
430
- this.map.off("zoom", token.fn);
431
- this.map.off("rotate", token.fn);
432
- this.map.off("pitch", token.fn);
433
- this.map.off("moveend", token.fn);
1130
+ for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
1131
+ }
1132
+ viewChangeEvents() {
1133
+ return this.viewChangeMode === "settle" ? ["moveend"] : [
1134
+ "zoom",
1135
+ "rotate",
1136
+ "pitch",
1137
+ "moveend"
1138
+ ];
434
1139
  }
435
1140
  createEditPointerDriver() {
436
1141
  const map = this.map;
@@ -550,40 +1255,75 @@ var MapLibreAdapter = class extends BaseMapAdapter {
550
1255
  setLayerStyle(layerId, style) {
551
1256
  const handle = this.layers.get(layerId);
552
1257
  if (!handle) return;
553
- handle.style = style;
1258
+ const resolvedStyle = {
1259
+ ...handle.style,
1260
+ ...style,
1261
+ ...style.text ? { text: {
1262
+ ...handle.style?.text,
1263
+ ...style.text
1264
+ } } : {}
1265
+ };
1266
+ handle.style = resolvedStyle;
554
1267
  for (const mlId of handle.mlLayerIds) {
555
- const layer = this.map.getLayer(mlId);
556
- if (!layer) continue;
557
- const type = layer.type;
558
- if (type === "fill") this.map.setPaintProperty(mlId, "fill-color", buildFillPaint(style)["fill-color"]);
559
- else if (type === "line") {
560
- if (style.strokeColor !== void 0 || style.strokeWidth !== void 0) {
561
- const paint = buildLinePaint(style);
562
- if (style.strokeColor !== void 0) this.map.setPaintProperty(mlId, "line-color", paint["line-color"]);
563
- if (style.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "line-width", paint["line-width"]);
564
- }
565
- if (style.strokeDash) this.map.setPaintProperty(mlId, "line-dasharray", style.strokeDash);
566
- } else if (type === "circle") {
567
- const paint = buildCirclePaint(style);
568
- this.map.setPaintProperty(mlId, "circle-radius", paint["circle-radius"]);
569
- this.map.setPaintProperty(mlId, "circle-color", paint["circle-color"]);
570
- this.map.setPaintProperty(mlId, "circle-stroke-color", paint["circle-stroke-color"]);
571
- this.map.setPaintProperty(mlId, "circle-stroke-width", paint["circle-stroke-width"]);
572
- this.map.setFilter(mlId, buildCirclePointFilter(style));
573
- } else if (type === "symbol" && style.text) {
574
- if (style.text.text) this.map.setLayoutProperty(mlId, "text-field", style.text.text);
575
- else if (style.text.labelProperty) this.map.setLayoutProperty(mlId, "text-field", ["get", style.text.labelProperty]);
576
- if (style.text.fillColor) this.map.setPaintProperty(mlId, "text-color", style.text.fillColor);
577
- if (style.text.strokeColor) this.map.setPaintProperty(mlId, "text-halo-color", style.text.strokeColor);
578
- if (style.text.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "text-halo-width", style.text.strokeWidth);
579
- }
1268
+ const channel = this.legacyPaintChannel(mlId);
1269
+ if (channel) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
580
1270
  }
1271
+ const bundlesNeedingOrder = /* @__PURE__ */ new Set();
1272
+ for (const [groupId, bundle] of handle.groupBundles) if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(bundle.features, resolvedStyle))) bundlesNeedingOrder.add(groupId);
1273
+ this.orderGroupBundles(handle, handle.groupOrder, handle.groupOrder, bundlesNeedingOrder);
1274
+ for (const bundle of handle.groupBundles.values()) for (const [channel, mlId] of bundle.layerIds) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
581
1275
  if (style.text && !handle.mlLayerIds.some((id) => id.endsWith("-symbol"))) {
582
1276
  const symbolId = `${layerId}-symbol`;
583
- this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, style.text));
1277
+ this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text));
584
1278
  handle.mlLayerIds.push(symbolId);
585
1279
  }
586
1280
  }
1281
+ legacyPaintChannel(layerId) {
1282
+ if (layerId.endsWith("-fill-pattern")) return "fill-pattern";
1283
+ if (layerId.endsWith("-fill")) return "fill";
1284
+ if (layerId.endsWith("-line")) return "line";
1285
+ if (layerId.endsWith("-circle")) return "circle";
1286
+ if (layerId.endsWith("-icon")) return "icon";
1287
+ if (layerId.endsWith("-symbol")) return "text";
1288
+ }
1289
+ applyChannelStyle(layerId, channel, styleUpdate, resolvedStyle) {
1290
+ if (!this.map.getLayer(layerId)) return;
1291
+ switch (channel) {
1292
+ case "fill":
1293
+ if (styleUpdate.fillColor === void 0) break;
1294
+ this.map.setPaintProperty(layerId, "fill-color", buildFillPaint(resolvedStyle)["fill-color"]);
1295
+ break;
1296
+ case "fill-pattern": break;
1297
+ case "line": {
1298
+ const paint = buildLinePaint(resolvedStyle);
1299
+ if (styleUpdate.strokeColor !== void 0) this.map.setPaintProperty(layerId, "line-color", paint["line-color"]);
1300
+ if (styleUpdate.strokeWidth !== void 0) this.map.setPaintProperty(layerId, "line-width", paint["line-width"]);
1301
+ if (styleUpdate.strokeDash !== void 0) this.map.setPaintProperty(layerId, "line-dasharray", paint["line-dasharray"]);
1302
+ break;
1303
+ }
1304
+ case "circle": {
1305
+ if (!(styleUpdate.pointRadius !== void 0 || styleUpdate.pointFillColor !== void 0 || styleUpdate.fillColor !== void 0 || styleUpdate.strokeColor !== void 0 || styleUpdate.strokeWidth !== void 0 || styleUpdate.text !== void 0)) break;
1306
+ const paint = buildCirclePaint(resolvedStyle);
1307
+ this.map.setPaintProperty(layerId, "circle-radius", paint["circle-radius"]);
1308
+ this.map.setPaintProperty(layerId, "circle-color", paint["circle-color"]);
1309
+ this.map.setPaintProperty(layerId, "circle-stroke-color", paint["circle-stroke-color"]);
1310
+ this.map.setPaintProperty(layerId, "circle-stroke-width", paint["circle-stroke-width"]);
1311
+ this.map.setFilter(layerId, buildCirclePointFilter(resolvedStyle));
1312
+ break;
1313
+ }
1314
+ case "icon": break;
1315
+ case "text":
1316
+ if (!styleUpdate.text || !resolvedStyle.text) break;
1317
+ if (styleUpdate.text.text || styleUpdate.text.labelProperty) this.map.setLayoutProperty(layerId, "text-field", buildTextFieldExpression(resolvedStyle.text));
1318
+ if (styleUpdate.text.fillColor) this.map.setPaintProperty(layerId, "text-color", resolvedStyle.text.fillColor);
1319
+ if (styleUpdate.text.strokeColor) this.map.setPaintProperty(layerId, "text-halo-color", buildTextHaloColorExpression(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
+ }
1324
+ break;
1325
+ }
1326
+ }
587
1327
  toLonLat(coordinate) {
588
1328
  return unproject(coordinate[0], coordinate[1]);
589
1329
  }
@@ -594,6 +1334,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
594
1334
  const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
595
1335
  return [point.x, point.y];
596
1336
  }
1337
+ getCoordinateFromPixel(pixel) {
1338
+ const coordinate = this.map.unproject(pixel);
1339
+ return [this.toContinuousLng(coordinate.lng), coordinate.lat];
1340
+ }
597
1341
  getViewportSize() {
598
1342
  const container = this.map.getContainer();
599
1343
  return {
@@ -639,20 +1383,48 @@ var MapLibreAdapter = class extends BaseMapAdapter {
639
1383
  const tol = pickHitTolerance(originalEvent);
640
1384
  const { x, y } = e.point;
641
1385
  const bbox = [[x - tol, y - tol], [x + tol, y + tol]];
642
- const features = this.map.queryRenderedFeatures(bbox, { layers: handle.mlLayerIds });
643
- if (features.length === 0) return;
644
- const f = features[0];
645
- const geojson = {
1386
+ const relevantFeatures = mergeNativeAndCachedPickFeatures(this.map.queryRenderedFeatures(bbox, { layers: this.nativeLayerIds(handle) }).map((feature) => ({
646
1387
  type: "Feature",
647
- id: f.id,
648
- geometry: f.geometry,
649
- properties: f.properties
650
- };
651
- const cmId = tryControlMeasureIdFromFeature(geojson);
1388
+ id: feature.id,
1389
+ geometry: feature.geometry,
1390
+ properties: feature.properties
1391
+ })), this.featureCache.get(layerId) ?? []);
1392
+ const size = this.getViewportSize();
1393
+ const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
1394
+ const iconId = feature.properties?.iconId;
1395
+ const icon = typeof iconId === "string" ? this.getIconResource(iconId) : void 0;
1396
+ const featureGroupOrder = this.getFeatureGroupOrder(layerId, feature.id);
1397
+ return {
1398
+ feature,
1399
+ visualOrder,
1400
+ nativeHit,
1401
+ ...featureGroupOrder !== void 0 ? { featureGroupOrder } : {},
1402
+ ...icon ? { icon } : {},
1403
+ layerStyle: {
1404
+ ...handle.style,
1405
+ strokeColor: handle.style?.strokeColor ?? DEFAULT_LINE_COLOR,
1406
+ strokeWidth: handle.style?.strokeWidth ?? DEFAULT_LINE_WIDTH
1407
+ }
1408
+ };
1409
+ }), {
1410
+ pixel: [x, y],
1411
+ pointerType: pickPointerType(originalEvent),
1412
+ tolerance: tol,
1413
+ toPixel: (position) => this.getPixelFromCoordinate(position),
1414
+ viewport: {
1415
+ minX: 0,
1416
+ minY: 0,
1417
+ maxX: size.width,
1418
+ maxY: size.height
1419
+ },
1420
+ bearing: this.getBearing()
1421
+ });
1422
+ if (!candidate) return;
1423
+ const cmId = candidate.graphicId;
652
1424
  if (cmId == null) return;
653
1425
  handler({
654
1426
  id: cmId,
655
- feature: geojson,
1427
+ feature: candidate.feature,
656
1428
  pixel: [x, y],
657
1429
  coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
658
1430
  originalEvent
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.8",
3
+ "version": "0.2.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",
@@ -37,17 +37,18 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@orbat-mapper/control-measures": "^0.2.0-alpha.15",
41
- "@orbat-mapper/tactical-draw": "^0.2.0-alpha.23"
40
+ "@orbat-mapper/control-measures": "^0.2.0",
41
+ "@orbat-mapper/tactical-draw": "^0.2.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "maplibre-gl": ">=4.0.0 <6.0.0"
44
+ "maplibre-gl": ">=5.8.0 <6.0.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@arethetypeswrong/core": "^0.18.3",
48
+ "@maplibre/maplibre-gl-style-spec": "^24.10.0",
48
49
  "@types/geojson": "^7946.0.16",
49
50
  "jsdom": "^27.4.0",
50
- "maplibre-gl": "^5.18.0",
51
+ "maplibre-gl": "^5.24.0",
51
52
  "publint": "^0.3.21",
52
53
  "tsdown": "^0.22.2",
53
54
  "typescript": "~5.9.3",