@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.9 → 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,14 +1,38 @@
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;
@@ -30,7 +54,33 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
30
54
  private toContinuousLng;
31
55
  protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
32
56
  protected destroyLayer(handle: MapLibreLayerHandle): void;
33
- 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;
34
84
  protected applyIconResources(resources: readonly IconResource[]): void;
35
85
  protected removeIconResource(id: string): void;
36
86
  protected attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void): NativeEventEntry<MapLibreEventToken>;
@@ -40,9 +90,12 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
40
90
  private viewChangeEvents;
41
91
  createEditPointerDriver(): EditPointerDriver;
42
92
  setLayerStyle(layerId: LayerId, style: StyleOptions): void;
93
+ private legacyPaintChannel;
94
+ private applyChannelStyle;
43
95
  toLonLat(coordinate: number[]): Position;
44
96
  fromLonLat(position: Position): number[];
45
97
  getPixelFromCoordinate(position: Position): PixelCoordinate | null;
98
+ getCoordinateFromPixel(pixel: PixelCoordinate): Position | null;
46
99
  getViewportSize(): {
47
100
  width: number;
48
101
  height: number;
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",
@@ -93,7 +202,8 @@ function buildIconLayer(id, source) {
93
202
  "icon-rotation-alignment": "viewport",
94
203
  "icon-pitch-alignment": "viewport",
95
204
  "icon-allow-overlap": true,
96
- "icon-ignore-placement": true
205
+ "icon-ignore-placement": true,
206
+ "symbol-z-order": "source"
97
207
  },
98
208
  paint: { "icon-opacity": [
99
209
  "coalesce",
@@ -122,7 +232,7 @@ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
122
232
  * matching `getResolution`); the linear ramp between stops approximates the
123
233
  * exponential exactly closely enough at 1-zoom granularity.
124
234
  */
125
- function buildTextSizeExpression(text) {
235
+ function textSizeAtZoom(text, zoom) {
126
236
  const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
127
237
  const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
128
238
  const screenOrDefault = [
@@ -130,33 +240,97 @@ function buildTextSizeExpression(text) {
130
240
  ["get", "textSizePixels"],
131
241
  14
132
242
  ];
133
- const sizeAtZoom = (zoom) => {
134
- 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
+ [
135
252
  "case",
136
- ["has", "textSizeMeters"],
253
+ ["has", "textMaxSizePixels"],
254
+ [
255
+ "min",
256
+ ["get", "textMaxSizePixels"],
257
+ groundPx
258
+ ],
137
259
  [
138
260
  "min",
139
261
  maxPx,
140
262
  [
141
263
  "max",
142
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
+ ">",
143
313
  [
144
314
  "*",
145
315
  ["get", "textSizeMeters"],
146
316
  Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
147
- ]
148
- ]
317
+ ],
318
+ ["get", "textMaxSizePixels"]
319
+ ],
320
+ 0,
321
+ 1
149
322
  ],
150
- screenOrDefault
323
+ 1
151
324
  ];
152
325
  };
326
+ const stops = Array.from({ length: 25 }, (_, zoom) => zoom);
153
327
  return [
154
- "interpolate",
155
- ["linear"],
328
+ "step",
156
329
  ["zoom"],
157
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
330
+ opacityAtZoom(0),
331
+ ...stops.slice(1).flatMap((zoom) => [zoom, opacityAtZoom(zoom)])
158
332
  ];
159
- }
333
+ })();
160
334
  /**
161
335
  * Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
162
336
  * a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
@@ -216,6 +390,133 @@ function buildIconSizeExpression() {
216
390
  * `properties.style.fillColor` overlay. Mirrors `buildCirclePaint`.
217
391
  */
218
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
+ /**
219
520
  * Build the layout and paint objects for a symbol (text) layer.
220
521
  * Centralises the rotation convention so `createLayer` and `setLayerStyle`
221
522
  * stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
@@ -264,26 +565,60 @@ function buildSymbolLayer(symbolId, sourceId, text) {
264
565
  "right",
265
566
  "center"
266
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";
267
584
  return {
268
585
  id: symbolId,
269
586
  type: "symbol",
270
587
  source: sourceId,
271
588
  layout: {
272
- "text-field": text.labelProperty ? ["get", text.labelProperty] : text.text ?? "",
273
- "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"],
274
606
  "text-size": buildTextSizeExpression(text),
275
607
  "text-rotate": textRotate,
276
608
  "text-anchor": textAnchor,
609
+ "text-justify": textJustify,
610
+ "text-max-width": 9999,
277
611
  "text-rotation-alignment": "map",
278
612
  "text-pitch-alignment": "map",
279
- "text-allow-overlap": true
613
+ "text-allow-overlap": true,
614
+ "symbol-z-order": "source"
280
615
  },
281
616
  paint: {
282
617
  "text-color": textColor,
283
- ...text.strokeColor ? {
284
- "text-halo-color": text.strokeColor,
285
- "text-halo-width": text.strokeWidth ?? 1
286
- } : {}
618
+ "text-halo-color": buildTextHaloColorExpression(text),
619
+ "text-halo-width": buildTextHaloWidthExpression(text),
620
+ "text-halo-blur": buildTextHaloBlurExpression(text),
621
+ "text-opacity": TEXT_OPACITY_EXPRESSION
287
622
  }
288
623
  };
289
624
  }
@@ -305,14 +640,79 @@ function buildFillPaint(style) {
305
640
  "fill-opacity": 1
306
641
  };
307
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];
308
698
  function buildLinePaint(style) {
309
- const colorDefault = style?.strokeColor ?? "#000";
310
- 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;
311
702
  const styleObj = [
312
703
  "coalesce",
313
704
  ["get", "style"],
314
705
  ["literal", {}]
315
706
  ];
707
+ const dashExpr = [
708
+ "coalesce",
709
+ [
710
+ "get",
711
+ "strokeDash",
712
+ styleObj
713
+ ],
714
+ ["literal", dashDefault]
715
+ ];
316
716
  return {
317
717
  "line-color": [
318
718
  "coalesce",
@@ -332,9 +732,54 @@ function buildLinePaint(style) {
332
732
  ],
333
733
  widthDefault
334
734
  ],
335
- ...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
+ ]
336
745
  };
337
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
+ }
338
783
  var MapLibreAdapter = class extends BaseMapAdapter {
339
784
  map;
340
785
  viewChangeMode;
@@ -361,14 +806,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
361
806
  id: fillId,
362
807
  type: "fill",
363
808
  source: sourceId,
364
- filter: [
365
- "==",
366
- "$type",
367
- "Polygon"
368
- ],
809
+ filter: buildFillFilter(false),
369
810
  paint: buildFillPaint(style)
370
811
  });
371
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);
372
822
  const lineId = `${layerId}-line`;
373
823
  this.map.addLayer({
374
824
  id: lineId,
@@ -414,29 +864,228 @@ var MapLibreAdapter = class extends BaseMapAdapter {
414
864
  return {
415
865
  sourceId,
416
866
  mlLayerIds,
867
+ groupBundles: /* @__PURE__ */ new Map(),
868
+ groupOrder: [],
869
+ nextGroupBundleId: 0,
417
870
  style
418
871
  };
419
872
  }
420
873
  destroyLayer(handle) {
874
+ this.clearGroupBundles(handle);
421
875
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
422
876
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
423
877
  }
424
- applyFeatureOps(layerId, ops) {
878
+ applyFeatureGroups(layerId, groups) {
425
879
  const handle = this.layers.get(layerId);
426
880
  if (!handle) return;
427
- const source = this.map.getSource(handle.sourceId);
428
- if (!source) return;
429
- const features = ops.full.map((f) => f.id !== void 0 ? {
430
- ...f,
431
- properties: {
432
- ...f.properties ?? {},
433
- _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);
434
895
  }
435
- } : f);
436
- source.setData({
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;
962
+ }
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({
437
1023
  type: "FeatureCollection",
438
1024
  features
439
- });
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 });
440
1089
  }
441
1090
  applyIconResources(resources) {
442
1091
  if (typeof Image === "undefined") return;
@@ -448,6 +1097,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
448
1097
  if (!this.pendingIconImages.has(resource.id)) return;
449
1098
  this.pendingIconImages.delete(resource.id);
450
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);
451
1101
  };
452
1102
  image.onerror = () => this.pendingIconImages.delete(resource.id);
453
1103
  image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
@@ -605,40 +1255,75 @@ var MapLibreAdapter = class extends BaseMapAdapter {
605
1255
  setLayerStyle(layerId, style) {
606
1256
  const handle = this.layers.get(layerId);
607
1257
  if (!handle) return;
608
- 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;
609
1267
  for (const mlId of handle.mlLayerIds) {
610
- const layer = this.map.getLayer(mlId);
611
- if (!layer) continue;
612
- const type = layer.type;
613
- if (type === "fill") this.map.setPaintProperty(mlId, "fill-color", buildFillPaint(style)["fill-color"]);
614
- else if (type === "line") {
615
- if (style.strokeColor !== void 0 || style.strokeWidth !== void 0) {
616
- const paint = buildLinePaint(style);
617
- if (style.strokeColor !== void 0) this.map.setPaintProperty(mlId, "line-color", paint["line-color"]);
618
- if (style.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "line-width", paint["line-width"]);
619
- }
620
- if (style.strokeDash) this.map.setPaintProperty(mlId, "line-dasharray", style.strokeDash);
621
- } else if (type === "circle") {
622
- const paint = buildCirclePaint(style);
623
- this.map.setPaintProperty(mlId, "circle-radius", paint["circle-radius"]);
624
- this.map.setPaintProperty(mlId, "circle-color", paint["circle-color"]);
625
- this.map.setPaintProperty(mlId, "circle-stroke-color", paint["circle-stroke-color"]);
626
- this.map.setPaintProperty(mlId, "circle-stroke-width", paint["circle-stroke-width"]);
627
- this.map.setFilter(mlId, buildCirclePointFilter(style));
628
- } else if (type === "symbol" && style.text) {
629
- if (style.text.text) this.map.setLayoutProperty(mlId, "text-field", style.text.text);
630
- else if (style.text.labelProperty) this.map.setLayoutProperty(mlId, "text-field", ["get", style.text.labelProperty]);
631
- if (style.text.fillColor) this.map.setPaintProperty(mlId, "text-color", style.text.fillColor);
632
- if (style.text.strokeColor) this.map.setPaintProperty(mlId, "text-halo-color", style.text.strokeColor);
633
- if (style.text.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "text-halo-width", style.text.strokeWidth);
634
- }
1268
+ const channel = this.legacyPaintChannel(mlId);
1269
+ if (channel) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
635
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);
636
1275
  if (style.text && !handle.mlLayerIds.some((id) => id.endsWith("-symbol"))) {
637
1276
  const symbolId = `${layerId}-symbol`;
638
- this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, style.text));
1277
+ this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text));
639
1278
  handle.mlLayerIds.push(symbolId);
640
1279
  }
641
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
+ }
642
1327
  toLonLat(coordinate) {
643
1328
  return unproject(coordinate[0], coordinate[1]);
644
1329
  }
@@ -649,6 +1334,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
649
1334
  const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
650
1335
  return [point.x, point.y];
651
1336
  }
1337
+ getCoordinateFromPixel(pixel) {
1338
+ const coordinate = this.map.unproject(pixel);
1339
+ return [this.toContinuousLng(coordinate.lng), coordinate.lat];
1340
+ }
652
1341
  getViewportSize() {
653
1342
  const container = this.map.getContainer();
654
1343
  return {
@@ -694,20 +1383,48 @@ var MapLibreAdapter = class extends BaseMapAdapter {
694
1383
  const tol = pickHitTolerance(originalEvent);
695
1384
  const { x, y } = e.point;
696
1385
  const bbox = [[x - tol, y - tol], [x + tol, y + tol]];
697
- const features = this.map.queryRenderedFeatures(bbox, { layers: handle.mlLayerIds });
698
- if (features.length === 0) return;
699
- const f = features[0];
700
- const geojson = {
1386
+ const relevantFeatures = mergeNativeAndCachedPickFeatures(this.map.queryRenderedFeatures(bbox, { layers: this.nativeLayerIds(handle) }).map((feature) => ({
701
1387
  type: "Feature",
702
- id: f.id,
703
- geometry: f.geometry,
704
- properties: f.properties
705
- };
706
- 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;
707
1424
  if (cmId == null) return;
708
1425
  handler({
709
1426
  id: cmId,
710
- feature: geojson,
1427
+ feature: candidate.feature,
711
1428
  pixel: [x, y],
712
1429
  coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
713
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.9",
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.24"
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",