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

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