@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.2 → 0.2.0-alpha.20

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,34 +1,88 @@
1
- import { BaseMapAdapter, EditPointerDriver, FeatureOps, 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
+ featureSignature: string;
21
+ requiredIconIds: ReadonlySet<string>;
22
+ }
12
23
  type MapLibreEventToken = (e: any) => void;
13
24
  interface MapLibreViewToken {
14
25
  fn: () => void;
15
26
  }
27
+ interface MapLibreAdapterOptions {
28
+ /**
29
+ * How view changes propagate to view-state refits. "continuous" (default)
30
+ * refits on every frame of an animated zoom/rotate/pitch; "settle" defers
31
+ * the refit until the animation ends.
32
+ */
33
+ viewChangeMode?: "continuous" | "settle";
34
+ }
16
35
  declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLibreEventToken, MapLibreViewToken> {
17
36
  private map;
18
- constructor(map: maplibregl.Map);
37
+ private readonly viewChangeMode;
38
+ private readonly pendingIconImages;
39
+ readonly scalesGroundPointSymbolsNatively = true;
40
+ constructor(map: maplibregl.Map, options?: MapLibreAdapterOptions);
19
41
  private toContinuousLng;
20
42
  protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
21
43
  protected destroyLayer(handle: MapLibreLayerHandle): void;
22
- protected applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
44
+ protected applyFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
45
+ private createGroupBundle;
46
+ private destroyGroupBundle;
47
+ private clearGroupBundles;
48
+ private requiredIconIds;
49
+ private reconcileGroupChannels;
50
+ private buildGroupLayer;
51
+ private updateGroupBundleSource;
52
+ private orderGroupBundles;
53
+ private nativeLayerIds;
54
+ /**
55
+ * Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
56
+ * and picks can key on the caller's feature id) and, for a hatch-patterned
57
+ * Polygon, `_fillPatternImageId` (registering the tile image on demand). At
58
+ * most one object spread — a feature needing neither passes through
59
+ * untouched.
60
+ */
61
+ private promoteFeature;
62
+ /**
63
+ * Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
64
+ * hatch-tile image id, registering the image on demand. Returns `undefined`
65
+ * for features without a hatch pattern, so `fillPatternImageId` stays the
66
+ * single source of truth for the id baked into `_fillPatternImageId`.
67
+ */
68
+ private resolveFillPatternImage;
69
+ /** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
70
+ private ensureFillPatternImage;
71
+ protected applyIconResources(resources: readonly IconResource[]): void;
72
+ protected removeIconResource(id: string): void;
23
73
  protected attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void): NativeEventEntry<MapLibreEventToken>;
24
74
  protected detachNativeEvent(entry: NativeEventEntry<MapLibreEventToken>): void;
25
75
  protected attachViewChange(handler: () => void): MapLibreViewToken;
26
76
  protected detachViewChange(token: MapLibreViewToken): void;
77
+ private viewChangeEvents;
27
78
  createEditPointerDriver(): EditPointerDriver;
28
79
  setLayerStyle(layerId: LayerId, style: StyleOptions): void;
80
+ private legacyPaintChannel;
81
+ private applyChannelStyle;
29
82
  toLonLat(coordinate: number[]): Position;
30
83
  fromLonLat(position: Position): number[];
31
84
  getPixelFromCoordinate(position: Position): PixelCoordinate | null;
85
+ getCoordinateFromPixel(pixel: PixelCoordinate): Position | null;
32
86
  getViewportSize(): {
33
87
  width: number;
34
88
  height: number;
@@ -40,8 +94,9 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
40
94
  setDoubleClickZoomEnabled(enabled: boolean): void;
41
95
  getResolution(): number | undefined;
42
96
  getZoom(): number | undefined;
97
+ getBearing(): number;
43
98
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
44
99
  setGlobeMode(enabled: boolean): void;
45
100
  }
46
101
  //#endregion
47
- export { MapLibreAdapter };
102
+ export { MapLibreAdapter, type MapLibreAdapterOptions };
package/dist/index.mjs CHANGED
@@ -1,6 +1,67 @@
1
1
  import { project, unproject } from "@orbat-mapper/control-measures";
2
- import { BaseMapAdapter, 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
+ /**
26
+ * Find the largest subset of bundles whose relative order already matches the
27
+ * desired stack. Reorders then move only the remaining bundles.
28
+ */
29
+ function longestStableGroupIds(previousGroupIds, desiredGroupIds) {
30
+ const previousIndices = new Map(previousGroupIds.map((groupId, index) => [groupId, index]));
31
+ const sequence = desiredGroupIds.flatMap((groupId) => {
32
+ const previousIndex = previousIndices.get(groupId);
33
+ return previousIndex === void 0 ? [] : [{
34
+ groupId,
35
+ previousIndex
36
+ }];
37
+ });
38
+ const tails = [];
39
+ const tailSequenceIndices = [];
40
+ const predecessors = new Array(sequence.length).fill(-1);
41
+ for (let sequenceIndex = 0; sequenceIndex < sequence.length; sequenceIndex++) {
42
+ const previousIndex = sequence[sequenceIndex].previousIndex;
43
+ let low = 0;
44
+ let high = tails.length;
45
+ while (low < high) {
46
+ const middle = low + high >>> 1;
47
+ if (tails[middle] < previousIndex) low = middle + 1;
48
+ else high = middle;
49
+ }
50
+ if (low > 0) predecessors[sequenceIndex] = tailSequenceIndices[low - 1];
51
+ tails[low] = previousIndex;
52
+ tailSequenceIndices[low] = sequenceIndex;
53
+ }
54
+ const stableGroupIds = /* @__PURE__ */ new Set();
55
+ let sequenceIndex = tailSequenceIndices.at(-1);
56
+ while (sequenceIndex !== void 0 && sequenceIndex >= 0) {
57
+ stableGroupIds.add(sequence[sequenceIndex].groupId);
58
+ sequenceIndex = predecessors[sequenceIndex];
59
+ }
60
+ return stableGroupIds;
61
+ }
62
+ function isPromiseLike(value) {
63
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
64
+ }
4
65
  function emptyFC() {
5
66
  return {
6
67
  type: "FeatureCollection",
@@ -52,51 +113,218 @@ function buildCirclePointFilter(style) {
52
113
  "$type",
53
114
  "Point"
54
115
  ],
116
+ ["!has", "iconId"],
55
117
  ["!has", style.text.labelProperty]
56
118
  ] : [
57
- "==",
58
- "$type",
59
- "Point"
119
+ "all",
120
+ [
121
+ "==",
122
+ "$type",
123
+ "Point"
124
+ ],
125
+ ["!has", "iconId"]
60
126
  ];
61
127
  }
62
- function buildTextSizeExpression() {
63
- const size = [
128
+ function buildIconLayer(id, source) {
129
+ return {
130
+ id,
131
+ type: "symbol",
132
+ source,
133
+ filter: [
134
+ "all",
135
+ [
136
+ "==",
137
+ "$type",
138
+ "Point"
139
+ ],
140
+ ["has", "iconId"]
141
+ ],
142
+ layout: {
143
+ "icon-image": ["get", "iconId"],
144
+ "icon-size": buildIconSizeExpression(),
145
+ "icon-rotate": [
146
+ "*",
147
+ [
148
+ "coalesce",
149
+ ["get", "iconRotation"],
150
+ 0
151
+ ],
152
+ 180 / Math.PI
153
+ ],
154
+ "icon-rotation-alignment": "viewport",
155
+ "icon-pitch-alignment": "viewport",
156
+ "icon-allow-overlap": true,
157
+ "icon-ignore-placement": true,
158
+ "symbol-z-order": "source"
159
+ },
160
+ paint: { "icon-opacity": [
161
+ "coalesce",
162
+ ["get", "iconOpacity"],
163
+ 1
164
+ ] }
165
+ };
166
+ }
167
+ function centeredSvg(resource) {
168
+ const halfWidth = Math.max(resource.anchor[0], resource.width - resource.anchor[0]);
169
+ const halfHeight = Math.max(resource.anchor[1], resource.height - resource.anchor[1]);
170
+ const width = halfWidth * 2;
171
+ const height = halfHeight * 2;
172
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><g transform="translate(${halfWidth - resource.anchor[0]} ${halfHeight - resource.anchor[1]})">${resource.svg}</g></svg>`;
173
+ }
174
+ /** Web Mercator world circumference in meters (2π × 6378137). */
175
+ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
176
+ /**
177
+ * Per-feature text size (ADR-0028): a ground-anchored label carries
178
+ * `textSizeMeters` and renders at `textSizeMeters / resolution`, clamped to
179
+ * the layer's `textSizeMinPixels`/`textSizeMaxPixels` band; a screen-anchored
180
+ * one carries `textSizePixels` and renders at that constant; neither renders
181
+ * the 14px engine default. Layout properties only take zoom as an
182
+ * `interpolate`/`step` input, so the ground conversion is evaluated at every
183
+ * integer zoom stop (resolution = world circumference / 512·2^zoom pixels,
184
+ * matching `getResolution`); the linear ramp between stops approximates the
185
+ * exponential exactly closely enough at 1-zoom granularity.
186
+ */
187
+ function buildTextSizeExpression(text) {
188
+ const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
189
+ const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
190
+ const screenOrDefault = [
64
191
  "coalesce",
65
192
  ["get", "textSizePixels"],
66
193
  14
67
194
  ];
68
- const referenceZoom = [
195
+ const sizeAtZoom = (zoom) => {
196
+ const groundPx = [
197
+ "*",
198
+ ["get", "textSizeMeters"],
199
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
200
+ ];
201
+ return [
202
+ "case",
203
+ ["has", "textSizeMeters"],
204
+ [
205
+ "case",
206
+ ["has", "textMaxSizePixels"],
207
+ [
208
+ "min",
209
+ ["get", "textMaxSizePixels"],
210
+ groundPx
211
+ ],
212
+ [
213
+ "min",
214
+ maxPx,
215
+ [
216
+ "max",
217
+ minPx,
218
+ groundPx
219
+ ]
220
+ ]
221
+ ],
222
+ screenOrDefault
223
+ ];
224
+ };
225
+ return [
226
+ "interpolate",
227
+ ["linear"],
228
+ ["zoom"],
229
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
230
+ ];
231
+ }
232
+ /**
233
+ * Companion to `buildTextSizeExpression` (ADR-0037): drives `text-opacity` to
234
+ * 0 once a ground-anchored Text graphic's derived on-screen size exceeds its
235
+ * `textMaxSizePixels` threshold, 1 otherwise (including every feature without
236
+ * a threshold, e.g. amplifier labels). Layout properties like `text-size`
237
+ * cannot represent "hidden", so hiding is a paint-property concern; `step`
238
+ * (rather than `interpolate`) is enough since opacity only ever needs to
239
+ * flip at a stop, evaluated at the same per-zoom granularity as the size
240
+ * expression.
241
+ */
242
+ const TEXT_OPACITY_EXPRESSION = (() => {
243
+ const opacityAtZoom = (zoom) => {
244
+ return [
245
+ "case",
246
+ [
247
+ "all",
248
+ ["has", "textSizeMeters"],
249
+ ["has", "textMaxSizePixels"]
250
+ ],
251
+ [
252
+ "case",
253
+ [
254
+ ">",
255
+ [
256
+ "*",
257
+ ["get", "textSizeMeters"],
258
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
259
+ ],
260
+ ["get", "textMaxSizePixels"]
261
+ ],
262
+ 0,
263
+ 1
264
+ ],
265
+ 1
266
+ ];
267
+ };
268
+ const stops = Array.from({ length: 25 }, (_, zoom) => zoom);
269
+ return [
270
+ "step",
271
+ ["zoom"],
272
+ opacityAtZoom(0),
273
+ ...stops.slice(1).flatMap((zoom) => [zoom, opacityAtZoom(zoom)])
274
+ ];
275
+ })();
276
+ /**
277
+ * Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
278
+ * a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
279
+ * optional `iconMinPixels`/`iconMaxPixels`), so the meters→pixels clamp is
280
+ * evaluated inside the style layer at every integer zoom stop and the symbol
281
+ * scales smoothly through animated zooms. Anything else falls back to the
282
+ * host-computed `iconScale` snapshot.
283
+ */
284
+ function buildIconSizeExpression() {
285
+ const minExpr = [
69
286
  "coalesce",
70
- ["get", "textSizeZoom"],
287
+ ["get", "iconMinPixels"],
71
288
  0
72
289
  ];
73
- const sizeAtZoom = (zoom) => [
74
- "min",
75
- 254,
76
- [
290
+ const maxExpr = [
291
+ "coalesce",
292
+ ["get", "iconMaxPixels"],
293
+ 1e9
294
+ ];
295
+ const scaleAtZoom = (zoom) => {
296
+ return [
77
297
  "case",
78
- ["has", "textSizeZoom"],
298
+ ["has", "iconSizeMeters"],
79
299
  [
80
- "*",
81
- size,
300
+ "/",
82
301
  [
83
- "^",
84
- 2,
302
+ "max",
303
+ minExpr,
85
304
  [
86
- "-",
87
- zoom,
88
- referenceZoom
305
+ "min",
306
+ maxExpr,
307
+ [
308
+ "*",
309
+ ["get", "iconSizeMeters"],
310
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
311
+ ]
89
312
  ]
90
- ]
313
+ ],
314
+ ["get", "iconOctagonSize"]
91
315
  ],
92
- size
93
- ]
94
- ];
316
+ [
317
+ "coalesce",
318
+ ["get", "iconScale"],
319
+ 1
320
+ ]
321
+ ];
322
+ };
95
323
  return [
96
324
  "interpolate",
97
325
  ["linear"],
98
326
  ["zoom"],
99
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
327
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, scaleAtZoom(zoom)]).flat()
100
328
  ];
101
329
  }
102
330
  /**
@@ -104,6 +332,94 @@ function buildTextSizeExpression() {
104
332
  * `properties.style.fillColor` overlay. Mirrors `buildCirclePaint`.
105
333
  */
106
334
  /**
335
+ * Per-feature `text-field` value: the layer's literal text, or the feature's
336
+ * `labelProperty` value, uppercased when the feature's `textStyle` is
337
+ * `"caps"`. Factored out of `buildSymbolLayer` so `setLayerStyle`'s
338
+ * incremental `text-field` update (below) can reuse it instead of
339
+ * regressing to a caps-unaware plain `["get", ...]`.
340
+ */
341
+ function buildTextFieldExpression(text) {
342
+ const rawTextField = text.labelProperty ? ["get", text.labelProperty] : text.text ?? "";
343
+ return text.labelProperty ? [
344
+ "case",
345
+ [
346
+ "==",
347
+ ["get", "textStyle"],
348
+ "caps"
349
+ ],
350
+ ["upcase", rawTextField],
351
+ rawTextField
352
+ ] : rawTextField;
353
+ }
354
+ const HAS_TEXT_HALO = [
355
+ "boolean",
356
+ ["get", "textHalo"],
357
+ false
358
+ ];
359
+ const HAS_TEXT_BACKGROUND = [
360
+ "boolean",
361
+ ["get", "textBackground"],
362
+ false
363
+ ];
364
+ /**
365
+ * Per-feature `text-halo-color` value: `textHalo` wins over both the layer's
366
+ * `text.strokeColor` and the `textBackground` halo, matching the OL/Leaflet
367
+ * adapters' precedence. Factored out so `setLayerStyle`'s incremental
368
+ * `text-halo-color` update (below) can reuse it instead of dropping the
369
+ * `textHalo` case.
370
+ */
371
+ function buildTextHaloColorExpression(text) {
372
+ const fallback = text.strokeColor ? text.strokeColor : [
373
+ "case",
374
+ HAS_TEXT_BACKGROUND,
375
+ "#ffffff",
376
+ "rgba(255,255,255,0)"
377
+ ];
378
+ return [
379
+ "case",
380
+ HAS_TEXT_HALO,
381
+ [
382
+ "coalesce",
383
+ ["get", "textHaloColor"],
384
+ TEXT_HALO_COLOR
385
+ ],
386
+ fallback
387
+ ];
388
+ }
389
+ /**
390
+ * Per-feature `text-halo-width` value, mirroring
391
+ * `buildTextHaloColorExpression`'s precedence. MapLibre's `text-halo-width`
392
+ * already extends outward from the glyph edge, matching
393
+ * `resolveTextHaloWidth`'s own outward-depth semantics, so the `textHalo`
394
+ * case uses its formula directly (unlike OpenLayers's straddling canvas
395
+ * stroke, which must double it).
396
+ */
397
+ function buildTextHaloWidthExpression(text) {
398
+ return [
399
+ "case",
400
+ HAS_TEXT_HALO,
401
+ [
402
+ "max",
403
+ 1.5,
404
+ [
405
+ "/",
406
+ [
407
+ "coalesce",
408
+ ["get", "textSizePixels"],
409
+ 24
410
+ ],
411
+ 8
412
+ ]
413
+ ],
414
+ text.strokeColor ? text.strokeWidth ?? 1 : [
415
+ "case",
416
+ HAS_TEXT_BACKGROUND,
417
+ 3,
418
+ 0
419
+ ]
420
+ ];
421
+ }
422
+ /**
107
423
  * Build the layout and paint objects for a symbol (text) layer.
108
424
  * Centralises the rotation convention so `createLayer` and `setLayerStyle`
109
425
  * stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
@@ -136,25 +452,75 @@ function buildSymbolLayer(symbolId, sourceId, text) {
136
452
  ],
137
453
  "#000"
138
454
  ];
455
+ const textAnchor = text.labelProperty ? [
456
+ "case",
457
+ [
458
+ "==",
459
+ ["get", "textAnchor"],
460
+ "start"
461
+ ],
462
+ "left",
463
+ [
464
+ "==",
465
+ ["get", "textAnchor"],
466
+ "end"
467
+ ],
468
+ "right",
469
+ "center"
470
+ ] : "center";
471
+ const textJustify = text.labelProperty ? [
472
+ "case",
473
+ [
474
+ "==",
475
+ ["get", "textJustify"],
476
+ "left"
477
+ ],
478
+ "left",
479
+ [
480
+ "==",
481
+ ["get", "textJustify"],
482
+ "right"
483
+ ],
484
+ "right",
485
+ "center"
486
+ ] : "center";
139
487
  return {
140
488
  id: symbolId,
141
489
  type: "symbol",
142
490
  source: sourceId,
143
491
  layout: {
144
- "text-field": text.labelProperty ? ["get", text.labelProperty] : text.text ?? "",
145
- "text-font": ["Open Sans Regular"],
146
- "text-size": buildTextSizeExpression(),
492
+ "text-field": buildTextFieldExpression(text),
493
+ "text-font": text.labelProperty ? [
494
+ "case",
495
+ [
496
+ "==",
497
+ ["get", "textStyle"],
498
+ "italic"
499
+ ],
500
+ ["literal", ["Open Sans Italic"]],
501
+ [
502
+ "==",
503
+ ["get", "textStyle"],
504
+ "light"
505
+ ],
506
+ ["literal", ["Open Sans Light"]],
507
+ ["literal", ["Open Sans Regular"]]
508
+ ] : ["Open Sans Regular"],
509
+ "text-size": buildTextSizeExpression(text),
147
510
  "text-rotate": textRotate,
511
+ "text-anchor": textAnchor,
512
+ "text-justify": textJustify,
513
+ "text-max-width": 9999,
148
514
  "text-rotation-alignment": "map",
149
515
  "text-pitch-alignment": "map",
150
- "text-allow-overlap": true
516
+ "text-allow-overlap": true,
517
+ "symbol-z-order": "source"
151
518
  },
152
519
  paint: {
153
520
  "text-color": textColor,
154
- ...text.strokeColor ? {
155
- "text-halo-color": text.strokeColor,
156
- "text-halo-width": text.strokeWidth ?? 1
157
- } : {}
521
+ "text-halo-color": buildTextHaloColorExpression(text),
522
+ "text-halo-width": buildTextHaloWidthExpression(text),
523
+ "text-opacity": TEXT_OPACITY_EXPRESSION
158
524
  }
159
525
  };
160
526
  }
@@ -176,14 +542,79 @@ function buildFillPaint(style) {
176
542
  "fill-opacity": 1
177
543
  };
178
544
  }
545
+ /**
546
+ * `properties.style.fillPattern` read as a GL expression, defaulting to
547
+ * `"solid"`. `fillPattern` is a per-feature-only field (ADR-0007) — unlike
548
+ * `fillColor`/`strokeColor` it has no layer-level `StyleOptions` default, so
549
+ * this expression takes no `style` argument.
550
+ */
551
+ function fillPatternField() {
552
+ return [
553
+ "coalesce",
554
+ [
555
+ "get",
556
+ "fillPattern",
557
+ [
558
+ "coalesce",
559
+ ["get", "style"],
560
+ ["literal", {}]
561
+ ]
562
+ ],
563
+ "solid"
564
+ ];
565
+ }
566
+ /**
567
+ * Filter for one of the two mutually-exclusive Polygon fill layers, keyed on
568
+ * whether the feature's resolved `fillPattern` is a hatch pattern. The solid
569
+ * `-fill` layer takes `hatched: false` (every Polygon except hatched ones);
570
+ * the `-fill-pattern` layer takes `hatched: true`. Split because MapLibre's
571
+ * `fill-color` and `fill-pattern` paints cannot coexist on one fill layer.
572
+ */
573
+ function buildFillFilter(hatched) {
574
+ const isHatched = [
575
+ "in",
576
+ fillPatternField(),
577
+ ["literal", [...HATCH_PATTERNS]]
578
+ ];
579
+ return [
580
+ "all",
581
+ [
582
+ "==",
583
+ ["geometry-type"],
584
+ "Polygon"
585
+ ],
586
+ hatched ? isHatched : ["!", isHatched]
587
+ ];
588
+ }
589
+ /**
590
+ * Paint for the hatch-pattern fill layer. The image id is baked per-feature
591
+ * into `properties._fillPatternImageId` (see `promoteFeature`), so the paint
592
+ * is a plain `["get", ...]` rather than re-deriving the id as a GL expression,
593
+ * keeping `fillPatternImageId` the single source of truth for the id string.
594
+ */
595
+ const FILL_PATTERN_PAINT = {
596
+ "fill-pattern": ["get", FILL_PATTERN_IMAGE_ID_PROPERTY],
597
+ "fill-opacity": 1
598
+ };
599
+ const SOLID_LINE_DASH = [1, 0];
179
600
  function buildLinePaint(style) {
180
- const colorDefault = style?.strokeColor ?? "#000";
181
- const widthDefault = style?.strokeWidth ?? 2;
601
+ const colorDefault = style?.strokeColor ?? DEFAULT_LINE_COLOR;
602
+ const widthDefault = style?.strokeWidth ?? DEFAULT_LINE_WIDTH;
603
+ const dashDefault = style?.strokeDash?.length ? [...style.strokeDash] : SOLID_LINE_DASH;
182
604
  const styleObj = [
183
605
  "coalesce",
184
606
  ["get", "style"],
185
607
  ["literal", {}]
186
608
  ];
609
+ const dashExpr = [
610
+ "coalesce",
611
+ [
612
+ "get",
613
+ "strokeDash",
614
+ styleObj
615
+ ],
616
+ ["literal", dashDefault]
617
+ ];
187
618
  return {
188
619
  "line-color": [
189
620
  "coalesce",
@@ -203,14 +634,63 @@ function buildLinePaint(style) {
203
634
  ],
204
635
  widthDefault
205
636
  ],
206
- ...style?.strokeDash ? { "line-dasharray": style.strokeDash } : {}
637
+ "line-dasharray": [
638
+ "case",
639
+ [
640
+ ">",
641
+ ["length", dashExpr],
642
+ 0
643
+ ],
644
+ dashExpr,
645
+ ["literal", SOLID_LINE_DASH]
646
+ ]
207
647
  };
208
648
  }
649
+ function geometryIncludes(feature, geometryTypes) {
650
+ const geometry = feature.geometry;
651
+ if (!geometry) return false;
652
+ if (geometry.type === "GeometryCollection") return geometry.geometries.some((candidate) => geometryTypes.has(candidate.type));
653
+ return geometryTypes.has(geometry.type);
654
+ }
655
+ const POLYGON_GEOMETRIES = new Set(["Polygon", "MultiPolygon"]);
656
+ const LINE_GEOMETRIES = new Set([
657
+ "LineString",
658
+ "MultiLineString",
659
+ "Polygon",
660
+ "MultiPolygon"
661
+ ]);
662
+ const POINT_GEOMETRIES = new Set(["Point", "MultiPoint"]);
663
+ function featureStyle(feature) {
664
+ const style = feature.properties?.style;
665
+ return style && typeof style === "object" ? style : {};
666
+ }
667
+ function groupPaintChannels(features, style) {
668
+ const channels = /* @__PURE__ */ new Set();
669
+ for (const feature of features) {
670
+ if (geometryIncludes(feature, POLYGON_GEOMETRIES)) {
671
+ const fillPattern = featureStyle(feature).fillPattern;
672
+ if (featureStyle(feature).fillColor !== void 0 || style?.fillColor !== void 0 || isHatchPattern(fillPattern)) channels.add(isHatchPattern(fillPattern) ? "fill-pattern" : "fill");
673
+ }
674
+ if (geometryIncludes(feature, LINE_GEOMETRIES)) channels.add("line");
675
+ if (!geometryIncludes(feature, POINT_GEOMETRIES)) continue;
676
+ const properties = feature.properties ?? {};
677
+ const hasIcon = properties.iconId != null;
678
+ const hasText = style?.text !== void 0 && (style.text.labelProperty ? properties[style.text.labelProperty] != null : style.text.text !== void 0);
679
+ if (hasIcon) channels.add("icon");
680
+ if (hasText) channels.add("text");
681
+ if (!hasIcon && !hasText) channels.add("circle");
682
+ }
683
+ return channels;
684
+ }
209
685
  var MapLibreAdapter = class extends BaseMapAdapter {
210
686
  map;
211
- constructor(map) {
687
+ viewChangeMode;
688
+ pendingIconImages = /* @__PURE__ */ new Set();
689
+ scalesGroundPointSymbolsNatively = true;
690
+ constructor(map, options = {}) {
212
691
  super();
213
692
  this.map = map;
693
+ this.viewChangeMode = options.viewChangeMode ?? "continuous";
214
694
  }
215
695
  toContinuousLng(lng) {
216
696
  return nearestWorldCopyLng(lng, this.map.getCenter().lng);
@@ -228,14 +708,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
228
708
  id: fillId,
229
709
  type: "fill",
230
710
  source: sourceId,
231
- filter: [
232
- "==",
233
- "$type",
234
- "Polygon"
235
- ],
711
+ filter: buildFillFilter(false),
236
712
  paint: buildFillPaint(style)
237
713
  });
238
714
  mlLayerIds.push(fillId);
715
+ const fillPatternId = `${layerId}-fill-pattern`;
716
+ this.map.addLayer({
717
+ id: fillPatternId,
718
+ type: "fill",
719
+ source: sourceId,
720
+ filter: buildFillFilter(true),
721
+ paint: FILL_PATTERN_PAINT
722
+ });
723
+ mlLayerIds.push(fillPatternId);
239
724
  const lineId = `${layerId}-line`;
240
725
  this.map.addLayer({
241
726
  id: lineId,
@@ -270,6 +755,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
270
755
  paint: buildCirclePaint(style)
271
756
  });
272
757
  mlLayerIds.push(circleId);
758
+ const iconId = `${layerId}-icon`;
759
+ this.map.addLayer(buildIconLayer(iconId, sourceId));
760
+ mlLayerIds.push(iconId);
273
761
  if (style?.text) {
274
762
  const symbolId = `${layerId}-symbol`;
275
763
  this.map.addLayer(buildSymbolLayer(symbolId, sourceId, style.text));
@@ -278,29 +766,238 @@ var MapLibreAdapter = class extends BaseMapAdapter {
278
766
  return {
279
767
  sourceId,
280
768
  mlLayerIds,
769
+ groupBundles: /* @__PURE__ */ new Map(),
770
+ groupOrder: [],
771
+ nextGroupBundleId: 0,
281
772
  style
282
773
  };
283
774
  }
284
775
  destroyLayer(handle) {
776
+ this.clearGroupBundles(handle);
285
777
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
286
778
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
287
779
  }
288
- applyFeatureOps(layerId, ops) {
780
+ applyFeatureGroups(layerId, groups) {
289
781
  const handle = this.layers.get(layerId);
290
782
  if (!handle) return;
291
- const source = this.map.getSource(handle.sourceId);
292
- if (!source) return;
293
- const features = ops.full.map((f) => f.id !== void 0 ? {
294
- ...f,
295
- properties: {
296
- ...f.properties ?? {},
297
- _fid: f.id
783
+ const desiredGroupIds = new Set(groups.map((group) => group.id));
784
+ const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
785
+ const bundlesNeedingOrder = /* @__PURE__ */ new Set();
786
+ for (const [groupId, bundle] of handle.groupBundles) {
787
+ if (desiredGroupIds.has(groupId)) continue;
788
+ this.destroyGroupBundle(bundle);
789
+ handle.groupBundles.delete(groupId);
790
+ }
791
+ for (const group of groups) {
792
+ let bundle = handle.groupBundles.get(group.id);
793
+ const isNewBundle = bundle === void 0;
794
+ if (!bundle) {
795
+ bundle = this.createGroupBundle(layerId, handle);
796
+ handle.groupBundles.set(group.id, bundle);
797
+ bundlesNeedingOrder.add(group.id);
798
+ }
799
+ const featureSignature = JSON.stringify(group.features);
800
+ const contentChanged = isNewBundle || bundle.featureSignature !== featureSignature;
801
+ const requiredIconIds = this.requiredIconIds(group);
802
+ const resourcesChanged = bundle.requiredIconIds.size !== requiredIconIds.size || [...bundle.requiredIconIds].some((id) => !requiredIconIds.has(id));
803
+ bundle.features = group.features;
804
+ bundle.featureSignature = featureSignature;
805
+ bundle.requiredIconIds = requiredIconIds;
806
+ if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(group.features, handle.style))) bundlesNeedingOrder.add(group.id);
807
+ if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
808
+ }
809
+ handle.groupOrder = groups.map((group) => group.id);
810
+ this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
811
+ }
812
+ createGroupBundle(layerId, handle) {
813
+ const sourceId = `${layerId}-group-${++handle.nextGroupBundleId}-src`;
814
+ this.map.addSource(sourceId, {
815
+ type: "geojson",
816
+ data: emptyFC(),
817
+ promoteId: "_fid"
818
+ });
819
+ return {
820
+ sourceId,
821
+ layerIds: /* @__PURE__ */ new Map(),
822
+ features: [],
823
+ featureSignature: "[]",
824
+ requiredIconIds: /* @__PURE__ */ new Set()
825
+ };
826
+ }
827
+ destroyGroupBundle(bundle) {
828
+ for (const layerId of bundle.layerIds.values()) if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
829
+ if (this.map.getSource(bundle.sourceId)) this.map.removeSource(bundle.sourceId);
830
+ }
831
+ clearGroupBundles(handle) {
832
+ for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
833
+ handle.groupBundles.clear();
834
+ handle.groupOrder = [];
835
+ }
836
+ requiredIconIds(group) {
837
+ const referenced = new Set(group.features.flatMap((feature) => {
838
+ const iconId = feature.properties?.iconId;
839
+ return typeof iconId === "string" ? [iconId] : [];
840
+ }));
841
+ return new Set(group.iconResources.map((resource) => resource.id).filter((resourceId) => referenced.has(resourceId)));
842
+ }
843
+ reconcileGroupChannels(handle, bundle, desiredChannels) {
844
+ let changed = false;
845
+ for (const channel of PAINT_CHANNELS) {
846
+ const layerId = bundle.layerIds.get(channel);
847
+ if (layerId && !desiredChannels.has(channel)) {
848
+ if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
849
+ bundle.layerIds.delete(channel);
850
+ changed = true;
851
+ } else if (!layerId && desiredChannels.has(channel)) {
852
+ const nextLayerId = `${bundle.sourceId}-${channel}`;
853
+ this.map.addLayer(this.buildGroupLayer(nextLayerId, bundle.sourceId, channel, handle.style), handle.mlLayerIds[0]);
854
+ bundle.layerIds.set(channel, nextLayerId);
855
+ changed = true;
298
856
  }
299
- } : f);
300
- source.setData({
857
+ }
858
+ return changed;
859
+ }
860
+ buildGroupLayer(id, sourceId, channel, style) {
861
+ switch (channel) {
862
+ case "fill": return {
863
+ id,
864
+ type: "fill",
865
+ source: sourceId,
866
+ filter: buildFillFilter(false),
867
+ paint: buildFillPaint(style)
868
+ };
869
+ case "fill-pattern": return {
870
+ id,
871
+ type: "fill",
872
+ source: sourceId,
873
+ filter: buildFillFilter(true),
874
+ paint: FILL_PATTERN_PAINT
875
+ };
876
+ case "line": return {
877
+ id,
878
+ type: "line",
879
+ source: sourceId,
880
+ filter: [
881
+ "any",
882
+ [
883
+ "==",
884
+ "$type",
885
+ "LineString"
886
+ ],
887
+ [
888
+ "==",
889
+ "$type",
890
+ "Polygon"
891
+ ]
892
+ ],
893
+ paint: buildLinePaint(style),
894
+ layout: {
895
+ "line-cap": "round",
896
+ "line-join": "round"
897
+ }
898
+ };
899
+ case "circle": return {
900
+ id,
901
+ type: "circle",
902
+ source: sourceId,
903
+ filter: buildCirclePointFilter(style),
904
+ paint: buildCirclePaint(style)
905
+ };
906
+ case "icon": return buildIconLayer(id, sourceId);
907
+ case "text":
908
+ if (!style?.text) throw new Error("Cannot create a MapLibre text channel without text style");
909
+ return buildSymbolLayer(id, sourceId, style.text);
910
+ }
911
+ }
912
+ updateGroupBundleSource(bundle) {
913
+ const source = this.map.getSource(bundle.sourceId);
914
+ if (!source) return;
915
+ const features = [...bundle.requiredIconIds].every((id) => this.map.hasImage(id)) ? bundle.features.map((feature) => this.promoteFeature(feature)) : [];
916
+ const result = source.setData({
301
917
  type: "FeatureCollection",
302
918
  features
303
- });
919
+ }, true);
920
+ if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
921
+ }
922
+ orderGroupBundles(handle, previousGroupIds, desiredGroupIds, forcedGroupIds) {
923
+ let beforeId = handle.mlLayerIds[0];
924
+ if (!beforeId) return;
925
+ const stableGroupIds = longestStableGroupIds(previousGroupIds, desiredGroupIds);
926
+ for (let index = desiredGroupIds.length - 1; index >= 0; index--) {
927
+ const groupId = desiredGroupIds[index];
928
+ const bundle = handle.groupBundles.get(groupId);
929
+ if (!bundle) continue;
930
+ if (!stableGroupIds.has(groupId) || forcedGroupIds.has(groupId)) for (const channel of PAINT_CHANNELS) {
931
+ const layerId = bundle.layerIds.get(channel);
932
+ if (layerId && this.map.getLayer(layerId)) this.map.moveLayer(layerId, beforeId);
933
+ }
934
+ beforeId = firstBundleLayerId(bundle) ?? beforeId;
935
+ }
936
+ }
937
+ nativeLayerIds(handle) {
938
+ return [...handle.mlLayerIds, ...[...handle.groupBundles.values()].flatMap((bundle) => [...bundle.layerIds.values()])];
939
+ }
940
+ /**
941
+ * Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
942
+ * and picks can key on the caller's feature id) and, for a hatch-patterned
943
+ * Polygon, `_fillPatternImageId` (registering the tile image on demand). At
944
+ * most one object spread — a feature needing neither passes through
945
+ * untouched.
946
+ */
947
+ promoteFeature(feature) {
948
+ const extra = {};
949
+ if (feature.id !== void 0) extra._fid = feature.id;
950
+ const imageId = this.resolveFillPatternImage(feature);
951
+ if (imageId !== void 0) extra[FILL_PATTERN_IMAGE_ID_PROPERTY] = imageId;
952
+ if (Object.keys(extra).length === 0) return feature;
953
+ return {
954
+ ...feature,
955
+ properties: {
956
+ ...feature.properties ?? {},
957
+ ...extra
958
+ }
959
+ };
960
+ }
961
+ /**
962
+ * Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
963
+ * hatch-tile image id, registering the image on demand. Returns `undefined`
964
+ * for features without a hatch pattern, so `fillPatternImageId` stays the
965
+ * single source of truth for the id baked into `_fillPatternImageId`.
966
+ */
967
+ resolveFillPatternImage(feature) {
968
+ const props = feature.properties;
969
+ const pattern = props?.style?.fillPattern;
970
+ if (!isHatchPattern(pattern)) return void 0;
971
+ const color = props?.fillColor ?? props?.style?.fillColor ?? "rgba(0,0,0,0)";
972
+ const imageId = fillPatternImageId(color, pattern);
973
+ this.ensureFillPatternImage(imageId, color, pattern);
974
+ return imageId;
975
+ }
976
+ /** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
977
+ ensureFillPatternImage(imageId, color, pattern) {
978
+ if (this.map.hasImage(imageId)) return;
979
+ const image = makeFillPatternImage(color, pattern, 2);
980
+ this.map.addImage(imageId, image, { pixelRatio: 2 });
981
+ }
982
+ applyIconResources(resources) {
983
+ if (typeof Image === "undefined") return;
984
+ for (const resource of resources) {
985
+ if (this.map.hasImage(resource.id) || this.pendingIconImages.has(resource.id)) continue;
986
+ this.pendingIconImages.add(resource.id);
987
+ const image = new Image();
988
+ image.onload = () => {
989
+ if (!this.pendingIconImages.has(resource.id)) return;
990
+ this.pendingIconImages.delete(resource.id);
991
+ if (!this.map.hasImage(resource.id)) this.map.addImage(resource.id, image);
992
+ for (const handle of this.layers.values()) for (const bundle of handle.groupBundles.values()) if (bundle.requiredIconIds.has(resource.id)) this.updateGroupBundleSource(bundle);
993
+ };
994
+ image.onerror = () => this.pendingIconImages.delete(resource.id);
995
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
996
+ }
997
+ }
998
+ removeIconResource(id) {
999
+ this.pendingIconImages.delete(id);
1000
+ if (this.map.hasImage(id)) this.map.removeImage(id);
304
1001
  }
305
1002
  attachNativeEvent(type, onEvent) {
306
1003
  const mlEvent = type === "pointermove" ? "mousemove" : type === "dblclick" ? "dblclick" : "click";
@@ -318,17 +1015,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
318
1015
  }
319
1016
  attachViewChange(handler) {
320
1017
  const fn = () => handler();
321
- this.map.on("zoom", fn);
322
- this.map.on("rotate", fn);
323
- this.map.on("pitch", fn);
324
- this.map.on("moveend", fn);
1018
+ for (const event of this.viewChangeEvents()) this.map.on(event, fn);
325
1019
  return { fn };
326
1020
  }
327
1021
  detachViewChange(token) {
328
- this.map.off("zoom", token.fn);
329
- this.map.off("rotate", token.fn);
330
- this.map.off("pitch", token.fn);
331
- this.map.off("moveend", token.fn);
1022
+ for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
1023
+ }
1024
+ viewChangeEvents() {
1025
+ return this.viewChangeMode === "settle" ? ["moveend"] : [
1026
+ "zoom",
1027
+ "rotate",
1028
+ "pitch",
1029
+ "moveend"
1030
+ ];
332
1031
  }
333
1032
  createEditPointerDriver() {
334
1033
  const map = this.map;
@@ -338,7 +1037,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
338
1037
  let disposed = false;
339
1038
  let touchDragActive = false;
340
1039
  let multiTouchCancelled = false;
341
- const TOUCH_INFO = { hitTolerance: TOUCH_HIT_TOLERANCE_PX };
1040
+ const touchInfo = (e) => ({
1041
+ hitTolerance: TOUCH_HIT_TOLERANCE_PX,
1042
+ native: e.originalEvent
1043
+ });
342
1044
  const preventMapEventDefaults = (e) => {
343
1045
  e.preventDefault();
344
1046
  e.originalEvent.preventDefault?.();
@@ -346,22 +1048,32 @@ var MapLibreAdapter = class extends BaseMapAdapter {
346
1048
  };
347
1049
  const onDown = (e) => {
348
1050
  const px = [e.point.x, e.point.y];
349
- const info = { altKey: e.originalEvent.altKey };
1051
+ const info = {
1052
+ altKey: e.originalEvent.altKey,
1053
+ shiftKey: e.originalEvent.shiftKey,
1054
+ native: e.originalEvent
1055
+ };
350
1056
  hub.fireDown(px, info);
351
1057
  };
352
1058
  const onMove = (e) => {
353
1059
  const px = [e.point.x, e.point.y];
354
- hub.fireMove(px);
1060
+ hub.fireMove(px, {
1061
+ shiftKey: e.originalEvent.shiftKey,
1062
+ native: e.originalEvent
1063
+ });
355
1064
  };
356
1065
  const onUp = (e) => {
357
1066
  const px = [e.point.x, e.point.y];
358
- hub.fireUp(px);
1067
+ hub.fireUp(px, {
1068
+ shiftKey: e.originalEvent.shiftKey,
1069
+ native: e.originalEvent
1070
+ });
359
1071
  };
360
1072
  const onTouchStart = (e) => {
361
1073
  if (e.points.length !== 1) return;
362
1074
  const before = dragPanWasEnabled;
363
1075
  const px = [e.point.x, e.point.y];
364
- hub.fireDown(px, TOUCH_INFO);
1076
+ hub.fireDown(px, touchInfo(e));
365
1077
  touchDragActive = dragPanWasEnabled !== before;
366
1078
  if (touchDragActive) preventMapEventDefaults(e);
367
1079
  };
@@ -370,20 +1082,20 @@ var MapLibreAdapter = class extends BaseMapAdapter {
370
1082
  const px = [e.point.x, e.point.y];
371
1083
  if (touchCount > 1 && touchDragActive && !multiTouchCancelled) {
372
1084
  multiTouchCancelled = true;
373
- hub.fireUp(px, TOUCH_INFO);
1085
+ hub.fireUp(px, touchInfo(e));
374
1086
  touchDragActive = false;
375
1087
  preventMapEventDefaults(e);
376
1088
  return;
377
1089
  }
378
1090
  if (touchCount > 1) return;
379
1091
  if (touchCount === 1) multiTouchCancelled = false;
380
- hub.fireMove(px, TOUCH_INFO);
1092
+ hub.fireMove(px, touchInfo(e));
381
1093
  if (touchDragActive) preventMapEventDefaults(e);
382
1094
  };
383
1095
  const onTouchEnd = (e) => {
384
1096
  multiTouchCancelled = false;
385
1097
  const px = [e.point.x, e.point.y];
386
- hub.fireUp(px, TOUCH_INFO);
1098
+ hub.fireUp(px, touchInfo(e));
387
1099
  touchDragActive = false;
388
1100
  };
389
1101
  map.on("mousedown", onDown);
@@ -435,40 +1147,72 @@ var MapLibreAdapter = class extends BaseMapAdapter {
435
1147
  setLayerStyle(layerId, style) {
436
1148
  const handle = this.layers.get(layerId);
437
1149
  if (!handle) return;
438
- handle.style = style;
1150
+ const resolvedStyle = {
1151
+ ...handle.style,
1152
+ ...style,
1153
+ ...style.text ? { text: {
1154
+ ...handle.style?.text,
1155
+ ...style.text
1156
+ } } : {}
1157
+ };
1158
+ handle.style = resolvedStyle;
439
1159
  for (const mlId of handle.mlLayerIds) {
440
- const layer = this.map.getLayer(mlId);
441
- if (!layer) continue;
442
- const type = layer.type;
443
- if (type === "fill") this.map.setPaintProperty(mlId, "fill-color", buildFillPaint(style)["fill-color"]);
444
- else if (type === "line") {
445
- if (style.strokeColor !== void 0 || style.strokeWidth !== void 0) {
446
- const paint = buildLinePaint(style);
447
- if (style.strokeColor !== void 0) this.map.setPaintProperty(mlId, "line-color", paint["line-color"]);
448
- if (style.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "line-width", paint["line-width"]);
449
- }
450
- if (style.strokeDash) this.map.setPaintProperty(mlId, "line-dasharray", style.strokeDash);
451
- } else if (type === "circle") {
452
- const paint = buildCirclePaint(style);
453
- this.map.setPaintProperty(mlId, "circle-radius", paint["circle-radius"]);
454
- this.map.setPaintProperty(mlId, "circle-color", paint["circle-color"]);
455
- this.map.setPaintProperty(mlId, "circle-stroke-color", paint["circle-stroke-color"]);
456
- this.map.setPaintProperty(mlId, "circle-stroke-width", paint["circle-stroke-width"]);
457
- this.map.setFilter(mlId, buildCirclePointFilter(style));
458
- } else if (type === "symbol" && style.text) {
459
- if (style.text.text) this.map.setLayoutProperty(mlId, "text-field", style.text.text);
460
- else if (style.text.labelProperty) this.map.setLayoutProperty(mlId, "text-field", ["get", style.text.labelProperty]);
461
- if (style.text.fillColor) this.map.setPaintProperty(mlId, "text-color", style.text.fillColor);
462
- if (style.text.strokeColor) this.map.setPaintProperty(mlId, "text-halo-color", style.text.strokeColor);
463
- if (style.text.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "text-halo-width", style.text.strokeWidth);
464
- }
1160
+ const channel = this.legacyPaintChannel(mlId);
1161
+ if (channel) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
465
1162
  }
1163
+ const bundlesNeedingOrder = /* @__PURE__ */ new Set();
1164
+ for (const [groupId, bundle] of handle.groupBundles) if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(bundle.features, resolvedStyle))) bundlesNeedingOrder.add(groupId);
1165
+ this.orderGroupBundles(handle, handle.groupOrder, handle.groupOrder, bundlesNeedingOrder);
1166
+ for (const bundle of handle.groupBundles.values()) for (const [channel, mlId] of bundle.layerIds) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
466
1167
  if (style.text && !handle.mlLayerIds.some((id) => id.endsWith("-symbol"))) {
467
1168
  const symbolId = `${layerId}-symbol`;
468
- this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, style.text));
1169
+ this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text));
469
1170
  handle.mlLayerIds.push(symbolId);
470
1171
  }
471
1172
  }
1173
+ legacyPaintChannel(layerId) {
1174
+ if (layerId.endsWith("-fill-pattern")) return "fill-pattern";
1175
+ if (layerId.endsWith("-fill")) return "fill";
1176
+ if (layerId.endsWith("-line")) return "line";
1177
+ if (layerId.endsWith("-circle")) return "circle";
1178
+ if (layerId.endsWith("-icon")) return "icon";
1179
+ if (layerId.endsWith("-symbol")) return "text";
1180
+ }
1181
+ applyChannelStyle(layerId, channel, styleUpdate, resolvedStyle) {
1182
+ if (!this.map.getLayer(layerId)) return;
1183
+ switch (channel) {
1184
+ case "fill":
1185
+ if (styleUpdate.fillColor === void 0) break;
1186
+ this.map.setPaintProperty(layerId, "fill-color", buildFillPaint(resolvedStyle)["fill-color"]);
1187
+ break;
1188
+ case "fill-pattern": break;
1189
+ case "line": {
1190
+ const paint = buildLinePaint(resolvedStyle);
1191
+ if (styleUpdate.strokeColor !== void 0) this.map.setPaintProperty(layerId, "line-color", paint["line-color"]);
1192
+ if (styleUpdate.strokeWidth !== void 0) this.map.setPaintProperty(layerId, "line-width", paint["line-width"]);
1193
+ if (styleUpdate.strokeDash !== void 0) this.map.setPaintProperty(layerId, "line-dasharray", paint["line-dasharray"]);
1194
+ break;
1195
+ }
1196
+ case "circle": {
1197
+ 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;
1198
+ const paint = buildCirclePaint(resolvedStyle);
1199
+ this.map.setPaintProperty(layerId, "circle-radius", paint["circle-radius"]);
1200
+ this.map.setPaintProperty(layerId, "circle-color", paint["circle-color"]);
1201
+ this.map.setPaintProperty(layerId, "circle-stroke-color", paint["circle-stroke-color"]);
1202
+ this.map.setPaintProperty(layerId, "circle-stroke-width", paint["circle-stroke-width"]);
1203
+ this.map.setFilter(layerId, buildCirclePointFilter(resolvedStyle));
1204
+ break;
1205
+ }
1206
+ case "icon": break;
1207
+ case "text":
1208
+ if (!styleUpdate.text || !resolvedStyle.text) break;
1209
+ if (styleUpdate.text.text || styleUpdate.text.labelProperty) this.map.setLayoutProperty(layerId, "text-field", buildTextFieldExpression(resolvedStyle.text));
1210
+ if (styleUpdate.text.fillColor) this.map.setPaintProperty(layerId, "text-color", resolvedStyle.text.fillColor);
1211
+ if (styleUpdate.text.strokeColor) this.map.setPaintProperty(layerId, "text-halo-color", buildTextHaloColorExpression(resolvedStyle.text));
1212
+ if (styleUpdate.text.strokeWidth !== void 0) this.map.setPaintProperty(layerId, "text-halo-width", buildTextHaloWidthExpression(resolvedStyle.text));
1213
+ break;
1214
+ }
1215
+ }
472
1216
  toLonLat(coordinate) {
473
1217
  return unproject(coordinate[0], coordinate[1]);
474
1218
  }
@@ -479,6 +1223,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
479
1223
  const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
480
1224
  return [point.x, point.y];
481
1225
  }
1226
+ getCoordinateFromPixel(pixel) {
1227
+ const coordinate = this.map.unproject(pixel);
1228
+ return [this.toContinuousLng(coordinate.lng), coordinate.lat];
1229
+ }
482
1230
  getViewportSize() {
483
1231
  const container = this.map.getContainer();
484
1232
  return {
@@ -508,13 +1256,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
508
1256
  else this.map.doubleClickZoom.disable();
509
1257
  }
510
1258
  getResolution() {
511
- const zoom = this.map.getZoom();
512
- const center = this.map.getCenter();
513
- return 40075016.686 * Math.cos(center.lat * Math.PI / 180) / Math.pow(2, zoom + 8);
1259
+ return WEB_MERCATOR_CIRCUMFERENCE / Math.pow(2, this.map.getZoom() + 9);
514
1260
  }
515
1261
  getZoom() {
516
1262
  return this.map.getZoom();
517
1263
  }
1264
+ getBearing() {
1265
+ return this.map.getBearing() * (Math.PI / 180);
1266
+ }
518
1267
  onPick(layerId, handler, opts) {
519
1268
  const onMlClick = (e) => {
520
1269
  const handle = this.layers.get(layerId);
@@ -523,20 +1272,48 @@ var MapLibreAdapter = class extends BaseMapAdapter {
523
1272
  const tol = pickHitTolerance(originalEvent);
524
1273
  const { x, y } = e.point;
525
1274
  const bbox = [[x - tol, y - tol], [x + tol, y + tol]];
526
- const features = this.map.queryRenderedFeatures(bbox, { layers: handle.mlLayerIds });
527
- if (features.length === 0) return;
528
- const f = features[0];
529
- const geojson = {
1275
+ const relevantFeatures = mergeNativeAndCachedPickFeatures(this.map.queryRenderedFeatures(bbox, { layers: this.nativeLayerIds(handle) }).map((feature) => ({
530
1276
  type: "Feature",
531
- id: f.id,
532
- geometry: f.geometry,
533
- properties: f.properties
534
- };
535
- const cmId = tryControlMeasureIdFromFeature(geojson);
1277
+ id: feature.id,
1278
+ geometry: feature.geometry,
1279
+ properties: feature.properties
1280
+ })), this.featureCache.get(layerId) ?? []);
1281
+ const size = this.getViewportSize();
1282
+ const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
1283
+ const iconId = feature.properties?.iconId;
1284
+ const icon = typeof iconId === "string" ? this.getIconResource(iconId) : void 0;
1285
+ const featureGroupOrder = this.getFeatureGroupOrder(layerId, feature.id);
1286
+ return {
1287
+ feature,
1288
+ visualOrder,
1289
+ nativeHit,
1290
+ ...featureGroupOrder !== void 0 ? { featureGroupOrder } : {},
1291
+ ...icon ? { icon } : {},
1292
+ layerStyle: {
1293
+ ...handle.style,
1294
+ strokeColor: handle.style?.strokeColor ?? DEFAULT_LINE_COLOR,
1295
+ strokeWidth: handle.style?.strokeWidth ?? DEFAULT_LINE_WIDTH
1296
+ }
1297
+ };
1298
+ }), {
1299
+ pixel: [x, y],
1300
+ pointerType: pickPointerType(originalEvent),
1301
+ tolerance: tol,
1302
+ toPixel: (position) => this.getPixelFromCoordinate(position),
1303
+ viewport: {
1304
+ minX: 0,
1305
+ minY: 0,
1306
+ maxX: size.width,
1307
+ maxY: size.height
1308
+ },
1309
+ bearing: this.getBearing()
1310
+ });
1311
+ if (!candidate) return;
1312
+ const cmId = candidate.graphicId;
536
1313
  if (cmId == null) return;
537
1314
  handler({
538
1315
  id: cmId,
539
- feature: geojson,
1316
+ feature: candidate.feature,
540
1317
  pixel: [x, y],
541
1318
  coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
542
1319
  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.2",
3
+ "version": "0.2.0-alpha.20",
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.7",
41
- "@orbat-mapper/tactical-draw": "^0.2.0-alpha.6"
40
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.25",
41
+ "@orbat-mapper/tactical-draw": "^0.2.0-alpha.35"
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",