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

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,10 @@ 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,
277
+ "text-max-width": 9999,
148
278
  "text-rotation-alignment": "map",
149
279
  "text-pitch-alignment": "map",
150
280
  "text-allow-overlap": true
@@ -208,9 +338,16 @@ function buildLinePaint(style) {
208
338
  }
209
339
  var MapLibreAdapter = class extends BaseMapAdapter {
210
340
  map;
211
- constructor(map) {
341
+ viewChangeMode;
342
+ pendingIconImages = /* @__PURE__ */ new Set();
343
+ scalesGroundPointSymbolsNatively = true;
344
+ constructor(map, options = {}) {
212
345
  super();
213
346
  this.map = map;
347
+ this.viewChangeMode = options.viewChangeMode ?? "continuous";
348
+ }
349
+ toContinuousLng(lng) {
350
+ return nearestWorldCopyLng(lng, this.map.getCenter().lng);
214
351
  }
215
352
  createLayer(layerId, style) {
216
353
  const sourceId = `${layerId}-src`;
@@ -267,6 +404,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
267
404
  paint: buildCirclePaint(style)
268
405
  });
269
406
  mlLayerIds.push(circleId);
407
+ const iconId = `${layerId}-icon`;
408
+ this.map.addLayer(buildIconLayer(iconId, sourceId));
409
+ mlLayerIds.push(iconId);
270
410
  if (style?.text) {
271
411
  const symbolId = `${layerId}-symbol`;
272
412
  this.map.addLayer(buildSymbolLayer(symbolId, sourceId, style.text));
@@ -299,10 +439,29 @@ var MapLibreAdapter = class extends BaseMapAdapter {
299
439
  features
300
440
  });
301
441
  }
442
+ applyIconResources(resources) {
443
+ if (typeof Image === "undefined") return;
444
+ for (const resource of resources) {
445
+ if (this.map.hasImage(resource.id) || this.pendingIconImages.has(resource.id)) continue;
446
+ this.pendingIconImages.add(resource.id);
447
+ const image = new Image();
448
+ image.onload = () => {
449
+ if (!this.pendingIconImages.has(resource.id)) return;
450
+ this.pendingIconImages.delete(resource.id);
451
+ if (!this.map.hasImage(resource.id)) this.map.addImage(resource.id, image);
452
+ };
453
+ image.onerror = () => this.pendingIconImages.delete(resource.id);
454
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
455
+ }
456
+ }
457
+ removeIconResource(id) {
458
+ this.pendingIconImages.delete(id);
459
+ if (this.map.hasImage(id)) this.map.removeImage(id);
460
+ }
302
461
  attachNativeEvent(type, onEvent) {
303
462
  const mlEvent = type === "pointermove" ? "mousemove" : type === "dblclick" ? "dblclick" : "click";
304
463
  const fn = (e) => {
305
- onEvent([e.lngLat.lng, e.lngLat.lat], [e.point.x, e.point.y], e);
464
+ onEvent([this.toContinuousLng(e.lngLat.lng), e.lngLat.lat], [e.point.x, e.point.y], e);
306
465
  };
307
466
  this.map.on(mlEvent, fn);
308
467
  return {
@@ -315,26 +474,32 @@ var MapLibreAdapter = class extends BaseMapAdapter {
315
474
  }
316
475
  attachViewChange(handler) {
317
476
  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);
477
+ for (const event of this.viewChangeEvents()) this.map.on(event, fn);
322
478
  return { fn };
323
479
  }
324
480
  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);
481
+ for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
482
+ }
483
+ viewChangeEvents() {
484
+ return this.viewChangeMode === "settle" ? ["moveend"] : [
485
+ "zoom",
486
+ "rotate",
487
+ "pitch",
488
+ "moveend"
489
+ ];
329
490
  }
330
491
  createEditPointerDriver() {
331
492
  const map = this.map;
493
+ const toContinuousLng = (lng) => this.toContinuousLng(lng);
332
494
  const hub = new PointerCallbackHub();
333
495
  let dragPanWasEnabled = false;
334
496
  let disposed = false;
335
497
  let touchDragActive = false;
336
498
  let multiTouchCancelled = false;
337
- const TOUCH_INFO = { hitTolerance: TOUCH_HIT_TOLERANCE_PX };
499
+ const touchInfo = (e) => ({
500
+ hitTolerance: TOUCH_HIT_TOLERANCE_PX,
501
+ native: e.originalEvent
502
+ });
338
503
  const preventMapEventDefaults = (e) => {
339
504
  e.preventDefault();
340
505
  e.originalEvent.preventDefault?.();
@@ -342,22 +507,32 @@ var MapLibreAdapter = class extends BaseMapAdapter {
342
507
  };
343
508
  const onDown = (e) => {
344
509
  const px = [e.point.x, e.point.y];
345
- const info = { altKey: e.originalEvent.altKey };
510
+ const info = {
511
+ altKey: e.originalEvent.altKey,
512
+ shiftKey: e.originalEvent.shiftKey,
513
+ native: e.originalEvent
514
+ };
346
515
  hub.fireDown(px, info);
347
516
  };
348
517
  const onMove = (e) => {
349
518
  const px = [e.point.x, e.point.y];
350
- hub.fireMove(px);
519
+ hub.fireMove(px, {
520
+ shiftKey: e.originalEvent.shiftKey,
521
+ native: e.originalEvent
522
+ });
351
523
  };
352
524
  const onUp = (e) => {
353
525
  const px = [e.point.x, e.point.y];
354
- hub.fireUp(px);
526
+ hub.fireUp(px, {
527
+ shiftKey: e.originalEvent.shiftKey,
528
+ native: e.originalEvent
529
+ });
355
530
  };
356
531
  const onTouchStart = (e) => {
357
532
  if (e.points.length !== 1) return;
358
533
  const before = dragPanWasEnabled;
359
534
  const px = [e.point.x, e.point.y];
360
- hub.fireDown(px, TOUCH_INFO);
535
+ hub.fireDown(px, touchInfo(e));
361
536
  touchDragActive = dragPanWasEnabled !== before;
362
537
  if (touchDragActive) preventMapEventDefaults(e);
363
538
  };
@@ -366,20 +541,20 @@ var MapLibreAdapter = class extends BaseMapAdapter {
366
541
  const px = [e.point.x, e.point.y];
367
542
  if (touchCount > 1 && touchDragActive && !multiTouchCancelled) {
368
543
  multiTouchCancelled = true;
369
- hub.fireUp(px, TOUCH_INFO);
544
+ hub.fireUp(px, touchInfo(e));
370
545
  touchDragActive = false;
371
546
  preventMapEventDefaults(e);
372
547
  return;
373
548
  }
374
549
  if (touchCount > 1) return;
375
550
  if (touchCount === 1) multiTouchCancelled = false;
376
- hub.fireMove(px, TOUCH_INFO);
551
+ hub.fireMove(px, touchInfo(e));
377
552
  if (touchDragActive) preventMapEventDefaults(e);
378
553
  };
379
554
  const onTouchEnd = (e) => {
380
555
  multiTouchCancelled = false;
381
556
  const px = [e.point.x, e.point.y];
382
- hub.fireUp(px, TOUCH_INFO);
557
+ hub.fireUp(px, touchInfo(e));
383
558
  touchDragActive = false;
384
559
  };
385
560
  map.on("mousedown", onDown);
@@ -403,12 +578,12 @@ var MapLibreAdapter = class extends BaseMapAdapter {
403
578
  }
404
579
  },
405
580
  toPixel(lonLat) {
406
- const p = map.project(lonLat);
581
+ const p = map.project([toContinuousLng(lonLat[0]), lonLat[1]]);
407
582
  return [p.x, p.y];
408
583
  },
409
584
  fromPixel(pixel) {
410
585
  const ll = map.unproject(pixel);
411
- return [ll.lng, ll.lat];
586
+ return [toContinuousLng(ll.lng), ll.lat];
412
587
  },
413
588
  dispose() {
414
589
  if (disposed) return;
@@ -472,9 +647,13 @@ var MapLibreAdapter = class extends BaseMapAdapter {
472
647
  return project(position[0], position[1]);
473
648
  }
474
649
  getPixelFromCoordinate(position) {
475
- const point = this.map.project(position);
650
+ const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
476
651
  return [point.x, point.y];
477
652
  }
653
+ getCoordinateFromPixel(pixel) {
654
+ const coordinate = this.map.unproject(pixel);
655
+ return [this.toContinuousLng(coordinate.lng), coordinate.lat];
656
+ }
478
657
  getViewportSize() {
479
658
  const container = this.map.getContainer();
480
659
  return {
@@ -504,13 +683,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
504
683
  else this.map.doubleClickZoom.disable();
505
684
  }
506
685
  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);
686
+ return WEB_MERCATOR_CIRCUMFERENCE / Math.pow(2, this.map.getZoom() + 9);
510
687
  }
511
688
  getZoom() {
512
689
  return this.map.getZoom();
513
690
  }
691
+ getBearing() {
692
+ return this.map.getBearing() * (Math.PI / 180);
693
+ }
514
694
  onPick(layerId, handler, opts) {
515
695
  const onMlClick = (e) => {
516
696
  const handle = this.layers.get(layerId);
@@ -534,7 +714,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
534
714
  id: cmId,
535
715
  feature: geojson,
536
716
  pixel: [x, y],
537
- coordinate: [e.lngLat.lng, e.lngLat.lat],
717
+ coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
538
718
  originalEvent
539
719
  });
540
720
  };
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.11",
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.26"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "maplibre-gl": ">=4.0.0 <6.0.0"