@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.1 → 0.2.0-alpha.10

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,4 +1,4 @@
1
- import { BaseMapAdapter, EditPointerDriver, FeatureOps, LayerId, MapCursor, NativeEventEntry, PickHandler, PickOptions, PixelCoordinate, StyleOptions } from "@orbat-mapper/tactical-draw";
1
+ import { BaseMapAdapter, EditPointerDriver, FeatureOps, IconResource, LayerId, MapCursor, NativeEventEntry, PickHandler, PickOptions, PixelCoordinate, StyleOptions } from "@orbat-mapper/tactical-draw";
2
2
  import maplibregl from "maplibre-gl";
3
3
  import { Position } from "geojson";
4
4
 
@@ -13,21 +13,37 @@ type MapLibreEventToken = (e: any) => void;
13
13
  interface MapLibreViewToken {
14
14
  fn: () => void;
15
15
  }
16
+ interface MapLibreAdapterOptions {
17
+ /**
18
+ * How view changes propagate to view-state refits. "continuous" (default)
19
+ * refits on every frame of an animated zoom/rotate/pitch; "settle" defers
20
+ * the refit until the animation ends.
21
+ */
22
+ viewChangeMode?: "continuous" | "settle";
23
+ }
16
24
  declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLibreEventToken, MapLibreViewToken> {
17
25
  private map;
18
- constructor(map: maplibregl.Map);
26
+ private readonly viewChangeMode;
27
+ private readonly pendingIconImages;
28
+ readonly scalesGroundPointSymbolsNatively = true;
29
+ constructor(map: maplibregl.Map, options?: MapLibreAdapterOptions);
30
+ private toContinuousLng;
19
31
  protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
20
32
  protected destroyLayer(handle: MapLibreLayerHandle): void;
21
33
  protected applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
34
+ protected applyIconResources(resources: readonly IconResource[]): void;
35
+ protected removeIconResource(id: string): void;
22
36
  protected attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void): NativeEventEntry<MapLibreEventToken>;
23
37
  protected detachNativeEvent(entry: NativeEventEntry<MapLibreEventToken>): void;
24
38
  protected attachViewChange(handler: () => void): MapLibreViewToken;
25
39
  protected detachViewChange(token: MapLibreViewToken): void;
40
+ private viewChangeEvents;
26
41
  createEditPointerDriver(): EditPointerDriver;
27
42
  setLayerStyle(layerId: LayerId, style: StyleOptions): void;
28
43
  toLonLat(coordinate: number[]): Position;
29
44
  fromLonLat(position: Position): number[];
30
45
  getPixelFromCoordinate(position: Position): PixelCoordinate | null;
46
+ getCoordinateFromPixel(pixel: PixelCoordinate): Position | null;
31
47
  getViewportSize(): {
32
48
  width: number;
33
49
  height: number;
@@ -39,8 +55,9 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
39
55
  setDoubleClickZoomEnabled(enabled: boolean): void;
40
56
  getResolution(): number | undefined;
41
57
  getZoom(): number | undefined;
58
+ getBearing(): number;
42
59
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
43
60
  setGlobeMode(enabled: boolean): void;
44
61
  }
45
62
  //#endregion
46
- export { MapLibreAdapter };
63
+ export { MapLibreAdapter, type MapLibreAdapterOptions };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { project, unproject } from "@orbat-mapper/control-measures";
2
- import { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, bindAbortable, pickHitTolerance, tryControlMeasureIdFromFeature } from "@orbat-mapper/tactical-draw";
2
+ import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, bindAbortable, nearestWorldCopyLng, pickHitTolerance, tryControlMeasureIdFromFeature } from "@orbat-mapper/tactical-draw";
3
3
  //#region src/ml-adapter.ts
4
4
  function emptyFC() {
5
5
  return {
@@ -52,51 +52,163 @@ function buildCirclePointFilter(style) {
52
52
  "$type",
53
53
  "Point"
54
54
  ],
55
+ ["!has", "iconId"],
55
56
  ["!has", style.text.labelProperty]
56
57
  ] : [
57
- "==",
58
- "$type",
59
- "Point"
58
+ "all",
59
+ [
60
+ "==",
61
+ "$type",
62
+ "Point"
63
+ ],
64
+ ["!has", "iconId"]
60
65
  ];
61
66
  }
62
- function buildTextSizeExpression() {
63
- const size = [
67
+ function buildIconLayer(id, source) {
68
+ return {
69
+ id,
70
+ type: "symbol",
71
+ source,
72
+ filter: [
73
+ "all",
74
+ [
75
+ "==",
76
+ "$type",
77
+ "Point"
78
+ ],
79
+ ["has", "iconId"]
80
+ ],
81
+ layout: {
82
+ "icon-image": ["get", "iconId"],
83
+ "icon-size": buildIconSizeExpression(),
84
+ "icon-rotate": [
85
+ "*",
86
+ [
87
+ "coalesce",
88
+ ["get", "iconRotation"],
89
+ 0
90
+ ],
91
+ 180 / Math.PI
92
+ ],
93
+ "icon-rotation-alignment": "viewport",
94
+ "icon-pitch-alignment": "viewport",
95
+ "icon-allow-overlap": true,
96
+ "icon-ignore-placement": true
97
+ },
98
+ paint: { "icon-opacity": [
99
+ "coalesce",
100
+ ["get", "iconOpacity"],
101
+ 1
102
+ ] }
103
+ };
104
+ }
105
+ function centeredSvg(resource) {
106
+ const halfWidth = Math.max(resource.anchor[0], resource.width - resource.anchor[0]);
107
+ const halfHeight = Math.max(resource.anchor[1], resource.height - resource.anchor[1]);
108
+ const width = halfWidth * 2;
109
+ const height = halfHeight * 2;
110
+ 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>`;
111
+ }
112
+ /** Web Mercator world circumference in meters (2π × 6378137). */
113
+ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
114
+ /**
115
+ * Per-feature text size (ADR-0028): a ground-anchored label carries
116
+ * `textSizeMeters` and renders at `textSizeMeters / resolution`, clamped to
117
+ * the layer's `textSizeMinPixels`/`textSizeMaxPixels` band; a screen-anchored
118
+ * one carries `textSizePixels` and renders at that constant; neither renders
119
+ * the 14px engine default. Layout properties only take zoom as an
120
+ * `interpolate`/`step` input, so the ground conversion is evaluated at every
121
+ * integer zoom stop (resolution = world circumference / 512·2^zoom pixels,
122
+ * matching `getResolution`); the linear ramp between stops approximates the
123
+ * exponential exactly closely enough at 1-zoom granularity.
124
+ */
125
+ function buildTextSizeExpression(text) {
126
+ const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
127
+ const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
128
+ const screenOrDefault = [
64
129
  "coalesce",
65
130
  ["get", "textSizePixels"],
66
131
  14
67
132
  ];
68
- const referenceZoom = [
133
+ const sizeAtZoom = (zoom) => {
134
+ return [
135
+ "case",
136
+ ["has", "textSizeMeters"],
137
+ [
138
+ "min",
139
+ maxPx,
140
+ [
141
+ "max",
142
+ minPx,
143
+ [
144
+ "*",
145
+ ["get", "textSizeMeters"],
146
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
147
+ ]
148
+ ]
149
+ ],
150
+ screenOrDefault
151
+ ];
152
+ };
153
+ return [
154
+ "interpolate",
155
+ ["linear"],
156
+ ["zoom"],
157
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
158
+ ];
159
+ }
160
+ /**
161
+ * Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
162
+ * a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
163
+ * optional `iconMinPixels`/`iconMaxPixels`), so the meters→pixels clamp is
164
+ * evaluated inside the style layer at every integer zoom stop and the symbol
165
+ * scales smoothly through animated zooms. Anything else falls back to the
166
+ * host-computed `iconScale` snapshot.
167
+ */
168
+ function buildIconSizeExpression() {
169
+ const minExpr = [
69
170
  "coalesce",
70
- ["get", "textSizeZoom"],
171
+ ["get", "iconMinPixels"],
71
172
  0
72
173
  ];
73
- const sizeAtZoom = (zoom) => [
74
- "min",
75
- 254,
76
- [
174
+ const maxExpr = [
175
+ "coalesce",
176
+ ["get", "iconMaxPixels"],
177
+ 1e9
178
+ ];
179
+ const scaleAtZoom = (zoom) => {
180
+ return [
77
181
  "case",
78
- ["has", "textSizeZoom"],
182
+ ["has", "iconSizeMeters"],
79
183
  [
80
- "*",
81
- size,
184
+ "/",
82
185
  [
83
- "^",
84
- 2,
186
+ "max",
187
+ minExpr,
85
188
  [
86
- "-",
87
- zoom,
88
- referenceZoom
189
+ "min",
190
+ maxExpr,
191
+ [
192
+ "*",
193
+ ["get", "iconSizeMeters"],
194
+ Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
195
+ ]
89
196
  ]
90
- ]
197
+ ],
198
+ ["get", "iconOctagonSize"]
91
199
  ],
92
- size
93
- ]
94
- ];
200
+ [
201
+ "coalesce",
202
+ ["get", "iconScale"],
203
+ 1
204
+ ]
205
+ ];
206
+ };
95
207
  return [
96
208
  "interpolate",
97
209
  ["linear"],
98
210
  ["zoom"],
99
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, sizeAtZoom(zoom)]).flat()
211
+ ...Array.from({ length: 25 }, (_, zoom) => [zoom, scaleAtZoom(zoom)]).flat()
100
212
  ];
101
213
  }
102
214
  /**
@@ -136,6 +248,22 @@ function buildSymbolLayer(symbolId, sourceId, text) {
136
248
  ],
137
249
  "#000"
138
250
  ];
251
+ const textAnchor = text.labelProperty ? [
252
+ "case",
253
+ [
254
+ "==",
255
+ ["get", "textAnchor"],
256
+ "start"
257
+ ],
258
+ "left",
259
+ [
260
+ "==",
261
+ ["get", "textAnchor"],
262
+ "end"
263
+ ],
264
+ "right",
265
+ "center"
266
+ ] : "center";
139
267
  return {
140
268
  id: symbolId,
141
269
  type: "symbol",
@@ -143,8 +271,9 @@ function buildSymbolLayer(symbolId, sourceId, text) {
143
271
  layout: {
144
272
  "text-field": text.labelProperty ? ["get", text.labelProperty] : text.text ?? "",
145
273
  "text-font": ["Open Sans Regular"],
146
- "text-size": buildTextSizeExpression(),
274
+ "text-size": buildTextSizeExpression(text),
147
275
  "text-rotate": textRotate,
276
+ "text-anchor": textAnchor,
148
277
  "text-rotation-alignment": "map",
149
278
  "text-pitch-alignment": "map",
150
279
  "text-allow-overlap": true
@@ -208,9 +337,16 @@ function buildLinePaint(style) {
208
337
  }
209
338
  var MapLibreAdapter = class extends BaseMapAdapter {
210
339
  map;
211
- constructor(map) {
340
+ viewChangeMode;
341
+ pendingIconImages = /* @__PURE__ */ new Set();
342
+ scalesGroundPointSymbolsNatively = true;
343
+ constructor(map, options = {}) {
212
344
  super();
213
345
  this.map = map;
346
+ this.viewChangeMode = options.viewChangeMode ?? "continuous";
347
+ }
348
+ toContinuousLng(lng) {
349
+ return nearestWorldCopyLng(lng, this.map.getCenter().lng);
214
350
  }
215
351
  createLayer(layerId, style) {
216
352
  const sourceId = `${layerId}-src`;
@@ -267,6 +403,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
267
403
  paint: buildCirclePaint(style)
268
404
  });
269
405
  mlLayerIds.push(circleId);
406
+ const iconId = `${layerId}-icon`;
407
+ this.map.addLayer(buildIconLayer(iconId, sourceId));
408
+ mlLayerIds.push(iconId);
270
409
  if (style?.text) {
271
410
  const symbolId = `${layerId}-symbol`;
272
411
  this.map.addLayer(buildSymbolLayer(symbolId, sourceId, style.text));
@@ -299,10 +438,29 @@ var MapLibreAdapter = class extends BaseMapAdapter {
299
438
  features
300
439
  });
301
440
  }
441
+ applyIconResources(resources) {
442
+ if (typeof Image === "undefined") return;
443
+ for (const resource of resources) {
444
+ if (this.map.hasImage(resource.id) || this.pendingIconImages.has(resource.id)) continue;
445
+ this.pendingIconImages.add(resource.id);
446
+ const image = new Image();
447
+ image.onload = () => {
448
+ if (!this.pendingIconImages.has(resource.id)) return;
449
+ this.pendingIconImages.delete(resource.id);
450
+ if (!this.map.hasImage(resource.id)) this.map.addImage(resource.id, image);
451
+ };
452
+ image.onerror = () => this.pendingIconImages.delete(resource.id);
453
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
454
+ }
455
+ }
456
+ removeIconResource(id) {
457
+ this.pendingIconImages.delete(id);
458
+ if (this.map.hasImage(id)) this.map.removeImage(id);
459
+ }
302
460
  attachNativeEvent(type, onEvent) {
303
461
  const mlEvent = type === "pointermove" ? "mousemove" : type === "dblclick" ? "dblclick" : "click";
304
462
  const fn = (e) => {
305
- onEvent([e.lngLat.lng, e.lngLat.lat], [e.point.x, e.point.y], e);
463
+ onEvent([this.toContinuousLng(e.lngLat.lng), e.lngLat.lat], [e.point.x, e.point.y], e);
306
464
  };
307
465
  this.map.on(mlEvent, fn);
308
466
  return {
@@ -315,26 +473,32 @@ var MapLibreAdapter = class extends BaseMapAdapter {
315
473
  }
316
474
  attachViewChange(handler) {
317
475
  const fn = () => handler();
318
- this.map.on("zoom", fn);
319
- this.map.on("rotate", fn);
320
- this.map.on("pitch", fn);
321
- this.map.on("moveend", fn);
476
+ for (const event of this.viewChangeEvents()) this.map.on(event, fn);
322
477
  return { fn };
323
478
  }
324
479
  detachViewChange(token) {
325
- this.map.off("zoom", token.fn);
326
- this.map.off("rotate", token.fn);
327
- this.map.off("pitch", token.fn);
328
- this.map.off("moveend", token.fn);
480
+ for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
481
+ }
482
+ viewChangeEvents() {
483
+ return this.viewChangeMode === "settle" ? ["moveend"] : [
484
+ "zoom",
485
+ "rotate",
486
+ "pitch",
487
+ "moveend"
488
+ ];
329
489
  }
330
490
  createEditPointerDriver() {
331
491
  const map = this.map;
492
+ const toContinuousLng = (lng) => this.toContinuousLng(lng);
332
493
  const hub = new PointerCallbackHub();
333
494
  let dragPanWasEnabled = false;
334
495
  let disposed = false;
335
496
  let touchDragActive = false;
336
497
  let multiTouchCancelled = false;
337
- const TOUCH_INFO = { hitTolerance: TOUCH_HIT_TOLERANCE_PX };
498
+ const touchInfo = (e) => ({
499
+ hitTolerance: TOUCH_HIT_TOLERANCE_PX,
500
+ native: e.originalEvent
501
+ });
338
502
  const preventMapEventDefaults = (e) => {
339
503
  e.preventDefault();
340
504
  e.originalEvent.preventDefault?.();
@@ -342,22 +506,32 @@ var MapLibreAdapter = class extends BaseMapAdapter {
342
506
  };
343
507
  const onDown = (e) => {
344
508
  const px = [e.point.x, e.point.y];
345
- const info = { altKey: e.originalEvent.altKey };
509
+ const info = {
510
+ altKey: e.originalEvent.altKey,
511
+ shiftKey: e.originalEvent.shiftKey,
512
+ native: e.originalEvent
513
+ };
346
514
  hub.fireDown(px, info);
347
515
  };
348
516
  const onMove = (e) => {
349
517
  const px = [e.point.x, e.point.y];
350
- hub.fireMove(px);
518
+ hub.fireMove(px, {
519
+ shiftKey: e.originalEvent.shiftKey,
520
+ native: e.originalEvent
521
+ });
351
522
  };
352
523
  const onUp = (e) => {
353
524
  const px = [e.point.x, e.point.y];
354
- hub.fireUp(px);
525
+ hub.fireUp(px, {
526
+ shiftKey: e.originalEvent.shiftKey,
527
+ native: e.originalEvent
528
+ });
355
529
  };
356
530
  const onTouchStart = (e) => {
357
531
  if (e.points.length !== 1) return;
358
532
  const before = dragPanWasEnabled;
359
533
  const px = [e.point.x, e.point.y];
360
- hub.fireDown(px, TOUCH_INFO);
534
+ hub.fireDown(px, touchInfo(e));
361
535
  touchDragActive = dragPanWasEnabled !== before;
362
536
  if (touchDragActive) preventMapEventDefaults(e);
363
537
  };
@@ -366,20 +540,20 @@ var MapLibreAdapter = class extends BaseMapAdapter {
366
540
  const px = [e.point.x, e.point.y];
367
541
  if (touchCount > 1 && touchDragActive && !multiTouchCancelled) {
368
542
  multiTouchCancelled = true;
369
- hub.fireUp(px, TOUCH_INFO);
543
+ hub.fireUp(px, touchInfo(e));
370
544
  touchDragActive = false;
371
545
  preventMapEventDefaults(e);
372
546
  return;
373
547
  }
374
548
  if (touchCount > 1) return;
375
549
  if (touchCount === 1) multiTouchCancelled = false;
376
- hub.fireMove(px, TOUCH_INFO);
550
+ hub.fireMove(px, touchInfo(e));
377
551
  if (touchDragActive) preventMapEventDefaults(e);
378
552
  };
379
553
  const onTouchEnd = (e) => {
380
554
  multiTouchCancelled = false;
381
555
  const px = [e.point.x, e.point.y];
382
- hub.fireUp(px, TOUCH_INFO);
556
+ hub.fireUp(px, touchInfo(e));
383
557
  touchDragActive = false;
384
558
  };
385
559
  map.on("mousedown", onDown);
@@ -403,12 +577,12 @@ var MapLibreAdapter = class extends BaseMapAdapter {
403
577
  }
404
578
  },
405
579
  toPixel(lonLat) {
406
- const p = map.project(lonLat);
580
+ const p = map.project([toContinuousLng(lonLat[0]), lonLat[1]]);
407
581
  return [p.x, p.y];
408
582
  },
409
583
  fromPixel(pixel) {
410
584
  const ll = map.unproject(pixel);
411
- return [ll.lng, ll.lat];
585
+ return [toContinuousLng(ll.lng), ll.lat];
412
586
  },
413
587
  dispose() {
414
588
  if (disposed) return;
@@ -472,9 +646,13 @@ var MapLibreAdapter = class extends BaseMapAdapter {
472
646
  return project(position[0], position[1]);
473
647
  }
474
648
  getPixelFromCoordinate(position) {
475
- const point = this.map.project(position);
649
+ const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
476
650
  return [point.x, point.y];
477
651
  }
652
+ getCoordinateFromPixel(pixel) {
653
+ const coordinate = this.map.unproject(pixel);
654
+ return [this.toContinuousLng(coordinate.lng), coordinate.lat];
655
+ }
478
656
  getViewportSize() {
479
657
  const container = this.map.getContainer();
480
658
  return {
@@ -504,13 +682,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
504
682
  else this.map.doubleClickZoom.disable();
505
683
  }
506
684
  getResolution() {
507
- const zoom = this.map.getZoom();
508
- const center = this.map.getCenter();
509
- return 40075016.686 * Math.cos(center.lat * Math.PI / 180) / Math.pow(2, zoom + 8);
685
+ return WEB_MERCATOR_CIRCUMFERENCE / Math.pow(2, this.map.getZoom() + 9);
510
686
  }
511
687
  getZoom() {
512
688
  return this.map.getZoom();
513
689
  }
690
+ getBearing() {
691
+ return this.map.getBearing() * (Math.PI / 180);
692
+ }
514
693
  onPick(layerId, handler, opts) {
515
694
  const onMlClick = (e) => {
516
695
  const handle = this.layers.get(layerId);
@@ -534,7 +713,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
534
713
  id: cmId,
535
714
  feature: geojson,
536
715
  pixel: [x, y],
537
- coordinate: [e.lngLat.lng, e.lngLat.lat],
716
+ coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
538
717
  originalEvent
539
718
  });
540
719
  };
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.1",
3
+ "version": "0.2.0-alpha.10",
4
4
  "description": "MapLibre GL JS engine adapter for @orbat-mapper/tactical-draw. Draw and edit tactical control measures (MIL-STD-2525 / APP-6) on a MapLibre map.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",
@@ -37,8 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@orbat-mapper/control-measures": "^0.2.0-alpha.6",
41
- "@orbat-mapper/tactical-draw": "^0.2.0-alpha.5"
40
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.16",
41
+ "@orbat-mapper/tactical-draw": "^0.2.0-alpha.25"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "maplibre-gl": ">=4.0.0 <6.0.0"