@angular-helpers/openlayers 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,6 +176,94 @@ const handle = popups.openComponent({
176
176
 
177
177
  `open` is idempotent by `id` and updates the existing overlay in place. `openComponent` always recreates the `ComponentRef` on a repeated id and disposes the previous one (`appRef.detachView` + `ref.destroy`) to avoid CD leaks. Calls made before the map is ready are queued and replayed on `OlMapService.onReady`.
178
178
 
179
+ ## Military symbology
180
+
181
+ Available since `0.4.0` from `@angular-helpers/openlayers/military`.
182
+
183
+ Three pure-math geometry helpers (no extra deps) plus a NATO MIL-STD-2525 symbol helper backed by the optional [`milsymbol`](https://github.com/spatialillusions/milsymbol) peer dependency.
184
+
185
+ ```typescript
186
+ import { inject, signal } from '@angular/core';
187
+ import { OlMilitaryService } from '@angular-helpers/openlayers/military';
188
+ import type { Feature } from '@angular-helpers/openlayers/core';
189
+
190
+ @Component({
191
+ // …
192
+ imports: [OlMapComponent, OlVectorLayerComponent],
193
+ template: `
194
+ <ol-map [center]="[2.17, 41.38]" [zoom]="8">
195
+ <ol-tile-layer id="osm" source="osm" />
196
+ <ol-vector-layer id="military" [features]="features()" [zIndex]="10" />
197
+ </ol-map>
198
+ `,
199
+ })
200
+ export class MilDemo {
201
+ private ml = inject(OlMilitaryService);
202
+ features = signal<Feature[]>([]);
203
+
204
+ async ngOnInit() {
205
+ const ellipse = this.ml.createEllipse({
206
+ center: [2.17, 41.38],
207
+ semiMajor: 6_000,
208
+ semiMinor: 3_000,
209
+ rotation: Math.PI / 6,
210
+ });
211
+ const sector = this.ml.createSector({
212
+ center: [-0.38, 39.47],
213
+ radius: 8_000,
214
+ startAngle: Math.PI / 6,
215
+ endAngle: Math.PI / 2,
216
+ });
217
+ const donut = this.ml.createDonut({
218
+ center: [-5.99, 37.39],
219
+ innerRadius: 5_000,
220
+ outerRadius: 10_000,
221
+ });
222
+ const symbol = await this.ml.createMilSymbol({
223
+ sidc: 'SFGPUCI-----',
224
+ position: [-3.7, 40.42],
225
+ size: 36,
226
+ });
227
+ this.features.set([ellipse, sector, donut, symbol]);
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### Geometry helpers
233
+
234
+ | Method | Output | Notes |
235
+ | ----------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
236
+ | `createEllipse(config)` | `Feature<Polygon>` | Optional `rotation` in radians, configurable `segments` (default 64) |
237
+ | `createSector(config)` | `Feature<Polygon>` | Pie-slice (apex-arc-apex). `startAngle < endAngle ≤ start + 2π` |
238
+ | `createDonut(config)` | `Feature<Polygon>` | Two rings: outer CCW, inner CW (right-hand rule). Renders as an annular band with the basemap visible through the hole |
239
+
240
+ Coordinates are emitted in `EPSG:4326` (lon/lat) using a local tangent-plane projection. Accurate up to ~100 km from the center; for very large radii or polar regions, geodesic-correct math is on the Phase 3 roadmap.
241
+
242
+ ### MIL-STD-2525 symbols
243
+
244
+ `createMilSymbol` lazy-loads `milsymbol` on first use and returns a `Feature<Point>` with style metadata (`feature.style.icon`) so the vector layer renders it as an `ol/style/Icon`. The library is declared as an **optional peer dependency** — install it only if you use this helper:
245
+
246
+ ```bash
247
+ npm install milsymbol
248
+ ```
249
+
250
+ ```ts
251
+ const symbol = await ml.createMilSymbol({
252
+ sidc: 'SFGPUCI-----', // friendly infantry, ground unit
253
+ position: [-3.7, 40.42],
254
+ size: 36,
255
+ uniqueDesignation: 'A1',
256
+ });
257
+ ```
258
+
259
+ Three flavors:
260
+
261
+ - **`createMilSymbol(config)`** — async; lazy-loads on first call.
262
+ - **`createMilSymbolSync(config)`** — sync; throws if `milsymbol` is not loaded yet.
263
+ - **`preloadMilsymbol()`** — fire-and-forget on app init to make the first symbol render synchronous.
264
+
265
+ The service throws clearly on non-browser environments (`createMilSymbol` requires `window`).
266
+
179
267
  ## Architecture
180
268
 
181
269
  ### Data vs UI Separation
@@ -206,8 +294,8 @@ import {
206
294
  withInteractions,
207
295
  } from '@angular-helpers/openlayers/interactions';
208
296
 
209
- // Add military features (~10KB additional)
210
- import { OlEllipseFeatureComponent, withMilitary } from '@angular-helpers/openlayers/military';
297
+ // Add military features pure-math helpers + lazy-loaded milsymbol
298
+ import { OlMilitaryService, withMilitary } from '@angular-helpers/openlayers/military';
211
299
  ```
212
300
 
213
301
  ## API Reference
@@ -155,6 +155,7 @@ class OlMapComponent {
155
155
  mapDblClick = output();
156
156
  mapContainerRef = viewChild.required('mapContainer');
157
157
  map;
158
+ resizeObserver;
158
159
  constructor() {
159
160
  afterNextRender(() => this.initMap());
160
161
  effect(() => {
@@ -186,6 +187,19 @@ class OlMapComponent {
186
187
  });
187
188
  this.map = new OLMap({ target: container, view, layers: [] });
188
189
  this.mapService.setMap(this.map);
190
+ // Add ResizeObserver to handle container size changes (e.g. sidebars, window resize)
191
+ if (typeof ResizeObserver !== 'undefined') {
192
+ this.resizeObserver = new ResizeObserver(() => {
193
+ if (this.map) {
194
+ // Using requestAnimationFrame prevents "ResizeObserver loop limit exceeded" errors
195
+ requestAnimationFrame(() => {
196
+ if (this.map)
197
+ this.map.updateSize();
198
+ });
199
+ }
200
+ });
201
+ this.resizeObserver.observe(container);
202
+ }
189
203
  view.on('change:center', () => this.zoneHelper.runInsideAngular(() => this.emitViewChange()));
190
204
  view.on('change:resolution', () => this.zoneHelper.runInsideAngular(() => this.emitViewChange()));
191
205
  this.map.on('click', (e) => this.zoneHelper.runInsideAngular(() => this.mapClick.emit({
@@ -200,6 +214,10 @@ class OlMapComponent {
200
214
  this.emitViewChange();
201
215
  }
202
216
  destroyMap() {
217
+ if (this.resizeObserver) {
218
+ this.resizeObserver.disconnect();
219
+ this.resizeObserver = undefined;
220
+ }
203
221
  if (this.map) {
204
222
  this.zoneHelper.runOutsideAngular(() => {
205
223
  this.map.setTarget(undefined);
@@ -33,9 +33,20 @@ class InteractionStateService {
33
33
  modify$ = this.modifySubject.asObservable();
34
34
  /**
35
35
  * Adds a managed interaction to the state.
36
+ * If the interaction is marked as exclusive, it disables other exclusive interactions.
36
37
  * @param interaction - The interaction to add
37
38
  */
38
39
  addInteraction(interaction) {
40
+ if (interaction.config.exclusive !== false) {
41
+ // Disable other exclusive interactions to maintain mutual exclusivity
42
+ const currentInteractions = this.interactions();
43
+ for (const existing of currentInteractions) {
44
+ if (existing.id !== interaction.id && existing.config.exclusive !== false) {
45
+ existing.cleanup();
46
+ this.removeInteraction(existing.id);
47
+ }
48
+ }
49
+ }
39
50
  this.interactions.update((list) => [...list, interaction]);
40
51
  }
41
52
  /**
@@ -533,6 +544,7 @@ function withInteractions() {
533
544
  return {
534
545
  kind: 'interactions',
535
546
  providers: [
547
+ OlLayerService, // DX: interactions often need layers
536
548
  OlInteractionService,
537
549
  InteractionStateService,
538
550
  SelectInteractionService,
@@ -4,10 +4,13 @@ import VectorLayer from 'ol/layer/Vector';
4
4
  import TileLayer from 'ol/layer/Tile';
5
5
  import ImageLayer from 'ol/layer/Image';
6
6
  import VectorSource from 'ol/source/Vector';
7
- import { Feature } from 'ol';
7
+ import OLFeature from 'ol/Feature';
8
8
  import { Point, LineString, Polygon, Circle } from 'ol/geom';
9
9
  import { fromLonLat } from 'ol/proj';
10
- import { Style, Circle as Circle$1, Stroke, Fill } from 'ol/style';
10
+ import { getCenter } from 'ol/extent';
11
+ import { Style, Circle as Circle$1, Stroke, Fill, Icon } from 'ol/style';
12
+ import Text from 'ol/style/Text';
13
+ import ClusterSource from 'ol/source/Cluster';
11
14
  import OSM from 'ol/source/OSM';
12
15
  import XYZ from 'ol/source/XYZ';
13
16
  import TileWMS from 'ol/source/TileWMS';
@@ -16,6 +19,12 @@ import ImageStatic from 'ol/source/ImageStatic';
16
19
  import { OlMapService } from '@angular-helpers/openlayers/core';
17
20
 
18
21
  // OlLayerService
22
+ /**
23
+ * Internal property key used to stash the abstract style metadata on the
24
+ * underlying `ol/Feature` so the layer style function can resolve a
25
+ * per-feature visual without colliding with user `properties`.
26
+ */
27
+ const STYLE_PROP = '__angular_helpers_style__';
19
28
  class OlLayerService {
20
29
  mapService = inject(OlMapService);
21
30
  layerCache = new Map();
@@ -127,7 +136,15 @@ class OlLayerService {
127
136
  const layer = this.layerCache.get(id);
128
137
  if (!(layer instanceof VectorLayer))
129
138
  return;
130
- layer.getSource()?.clear();
139
+ const source = layer.getSource();
140
+ if (!source)
141
+ return;
142
+ // Handle Cluster source: clear the underlying VectorSource
143
+ const clusterSource = source;
144
+ const vectorSource = clusterSource.getSource
145
+ ? clusterSource.getSource()
146
+ : source;
147
+ vectorSource?.clear();
131
148
  }
132
149
  /**
133
150
  * Updates the features of a vector layer.
@@ -142,8 +159,15 @@ class OlLayerService {
142
159
  const source = layer.getSource();
143
160
  if (!source)
144
161
  return;
162
+ // Handle Cluster source: get the underlying VectorSource
163
+ const clusterSource = source;
164
+ const vectorSource = clusterSource.getSource
165
+ ? clusterSource.getSource()
166
+ : source;
167
+ if (!(vectorSource instanceof VectorSource))
168
+ return;
145
169
  // Get existing feature IDs from source
146
- const existingIds = new Set(source
170
+ const existingIds = new Set(vectorSource
147
171
  .getFeatures()
148
172
  .map((f) => f.getId())
149
173
  .filter((id) => id !== undefined));
@@ -178,14 +202,17 @@ class OlLayerService {
178
202
  else {
179
203
  geometry = new Point([0, 0]);
180
204
  }
181
- const olFeature = new Feature({
205
+ const olFeature = new OLFeature({
182
206
  geometry,
183
207
  ...feature.properties,
184
208
  });
209
+ if (feature.style) {
210
+ olFeature.set(STYLE_PROP, feature.style);
211
+ }
185
212
  olFeature.setId(feature.id);
186
213
  return olFeature;
187
214
  });
188
- source.addFeatures(olFeatures);
215
+ vectorSource.addFeatures(olFeatures);
189
216
  }
190
217
  }
191
218
  }
@@ -204,7 +231,7 @@ class OlLayerService {
204
231
  this.layerState.set(layers.sort((a, b) => a.zIndex - b.zIndex));
205
232
  }
206
233
  createVectorLayer(config, map) {
207
- const source = new VectorSource();
234
+ const vectorSource = new VectorSource();
208
235
  // Add features if provided
209
236
  if (config.features && config.features.length > 0) {
210
237
  const olFeatures = config.features.map((feature) => {
@@ -234,15 +261,40 @@ class OlLayerService {
234
261
  else {
235
262
  geometry = new Point([0, 0]);
236
263
  }
237
- const olFeature = new Feature({
264
+ const olFeature = new OLFeature({
238
265
  geometry,
239
266
  ...feature.properties,
240
267
  });
268
+ if (feature.style) {
269
+ olFeature.set(STYLE_PROP, feature.style);
270
+ }
241
271
  olFeature.setId(feature.id);
242
272
  return olFeature;
243
273
  });
244
- source.addFeatures(olFeatures);
274
+ vectorSource.addFeatures(olFeatures);
245
275
  }
276
+ // Wrap in cluster source if enabled
277
+ const clusterCfg = config.cluster;
278
+ const source = clusterCfg?.enabled
279
+ ? new ClusterSource({
280
+ source: vectorSource,
281
+ distance: clusterCfg.distance ?? 40,
282
+ minDistance: clusterCfg.minDistance ?? 20,
283
+ geometryFunction: (feature) => {
284
+ const geometry = feature.getGeometry();
285
+ if (!geometry)
286
+ return null;
287
+ // For Point geometries, use as-is
288
+ if (geometry.getType() === 'Point') {
289
+ return geometry;
290
+ }
291
+ // For other geometries (Polygon, Circle, etc.), use center point
292
+ const extent = geometry.getExtent();
293
+ const center = getCenter(extent);
294
+ return new Point(center);
295
+ },
296
+ })
297
+ : vectorSource;
246
298
  // Default style for all geometry types (points, lines, polygons)
247
299
  const defaultStyle = new Style({
248
300
  fill: new Fill({ color: 'rgba(25, 118, 210, 0.3)' }),
@@ -253,12 +305,86 @@ class OlLayerService {
253
305
  stroke: new Stroke({ color: '#d32f2f', width: 2 }),
254
306
  }),
255
307
  });
308
+ // Cluster style: shows count badge when features are clustered
309
+ const clusterStyleFn = (olFeature) => {
310
+ const features = olFeature.get('features');
311
+ const size = features?.length ?? 1;
312
+ if (size > 1) {
313
+ const showCount = clusterCfg?.showCount ?? true;
314
+ return new Style({
315
+ image: new Circle$1({
316
+ radius: 15 + Math.min(size * 2, 15),
317
+ fill: new Fill({ color: 'rgba(255, 100, 100, 0.8)' }),
318
+ stroke: new Stroke({ color: '#fff', width: 2 }),
319
+ }),
320
+ text: showCount
321
+ ? new Text({
322
+ text: String(size),
323
+ fill: new Fill({ color: '#fff' }),
324
+ })
325
+ : undefined,
326
+ });
327
+ }
328
+ // Single feature: get the original feature from the cluster and use its style
329
+ const originalFeatures = olFeature.get('features');
330
+ const originalFeature = originalFeatures?.[0];
331
+ if (originalFeature) {
332
+ const abstractStyle = originalFeature.get(STYLE_PROP);
333
+ if (abstractStyle) {
334
+ const style = new Style();
335
+ const { icon, fill, stroke } = abstractStyle;
336
+ if (icon?.src) {
337
+ style.setImage(new Icon({
338
+ src: icon.src,
339
+ ...(icon.size ? { size: icon.size } : {}),
340
+ ...(icon.anchor ? { anchor: icon.anchor } : {}),
341
+ }));
342
+ }
343
+ if (fill) {
344
+ style.setFill(new Fill({ color: fill.color }));
345
+ }
346
+ if (stroke) {
347
+ style.setStroke(new Stroke({ color: stroke.color, width: stroke.width }));
348
+ }
349
+ // If we mapped at least one property, return it, otherwise fallback
350
+ if (icon?.src || fill || stroke)
351
+ return style;
352
+ }
353
+ }
354
+ return defaultStyle;
355
+ };
356
+ // Per-feature style resolver: features carrying `style` render it.
357
+ // Structural type avoids importing `FeatureLike` from `ol/Feature`;
358
+ // tooling has been observed to auto-remove the unused-looking import.
359
+ const styleFn = (olFeature) => {
360
+ const abstractStyle = olFeature.get(STYLE_PROP);
361
+ if (abstractStyle) {
362
+ const style = new Style();
363
+ const { icon, fill, stroke } = abstractStyle;
364
+ if (icon?.src) {
365
+ style.setImage(new Icon({
366
+ src: icon.src,
367
+ ...(icon.size ? { size: icon.size } : {}),
368
+ ...(icon.anchor ? { anchor: icon.anchor } : {}),
369
+ }));
370
+ }
371
+ if (fill) {
372
+ style.setFill(new Fill({ color: fill.color }));
373
+ }
374
+ if (stroke) {
375
+ style.setStroke(new Stroke({ color: stroke.color, width: stroke.width }));
376
+ }
377
+ if (icon?.src || fill || stroke)
378
+ return style;
379
+ }
380
+ return defaultStyle;
381
+ };
256
382
  const layer = new VectorLayer({
257
383
  source,
258
384
  visible: config.visible ?? true,
259
385
  opacity: config.opacity ?? 1,
260
386
  zIndex: config.zIndex,
261
- style: defaultStyle,
387
+ style: clusterCfg?.enabled ? clusterStyleFn : styleFn,
262
388
  });
263
389
  layer.set('id', config.id);
264
390
  map.addLayer(layer);
@@ -337,6 +463,7 @@ class OlVectorLayerComponent {
337
463
  opacity = input(1, ...(ngDevMode ? [{ debugName: "opacity" }] : /* istanbul ignore next */ []));
338
464
  visible = input(true, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
339
465
  style = input(...(ngDevMode ? [undefined, { debugName: "style" }] : /* istanbul ignore next */ []));
466
+ cluster = input(...(ngDevMode ? [undefined, { debugName: "cluster" }] : /* istanbul ignore next */ []));
340
467
  constructor() {
341
468
  // Initialize layer after DOM is ready
342
469
  afterNextRender(() => {
@@ -348,6 +475,7 @@ class OlVectorLayerComponent {
348
475
  opacity: this.opacity(),
349
476
  visible: this.visible(),
350
477
  style: this.style(),
478
+ cluster: this.cluster(),
351
479
  });
352
480
  });
353
481
  // Effect to sync features when input changes
@@ -364,7 +492,7 @@ class OlVectorLayerComponent {
364
492
  });
365
493
  }
366
494
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlVectorLayerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
367
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.4", type: OlVectorLayerComponent, isStandalone: true, selector: "ol-vector-layer", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: true, transformFunction: null }, features: { classPropertyName: "features", publicName: "features", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null }, opacity: { classPropertyName: "opacity", publicName: "opacity", isSignal: true, isRequired: false, transformFunction: null }, visible: { classPropertyName: "visible", publicName: "visible", isSignal: true, isRequired: false, transformFunction: null }, style: { classPropertyName: "style", publicName: "style", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
495
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.4", type: OlVectorLayerComponent, isStandalone: true, selector: "ol-vector-layer", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: true, transformFunction: null }, features: { classPropertyName: "features", publicName: "features", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null }, opacity: { classPropertyName: "opacity", publicName: "opacity", isSignal: true, isRequired: false, transformFunction: null }, visible: { classPropertyName: "visible", publicName: "visible", isSignal: true, isRequired: false, transformFunction: null }, style: { classPropertyName: "style", publicName: "style", isSignal: true, isRequired: false, transformFunction: null }, cluster: { classPropertyName: "cluster", publicName: "cluster", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
368
496
  }
369
497
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlVectorLayerComponent, decorators: [{
370
498
  type: Component,
@@ -373,7 +501,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
373
501
  template: '',
374
502
  changeDetection: ChangeDetectionStrategy.OnPush,
375
503
  }]
376
- }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: true }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }], opacity: [{ type: i0.Input, args: [{ isSignal: true, alias: "opacity", required: false }] }], visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "visible", required: false }] }], style: [{ type: i0.Input, args: [{ isSignal: true, alias: "style", required: false }] }] } });
504
+ }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: true }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }], opacity: [{ type: i0.Input, args: [{ isSignal: true, alias: "opacity", required: false }] }], visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "visible", required: false }] }], style: [{ type: i0.Input, args: [{ isSignal: true, alias: "style", required: false }] }], cluster: [{ type: i0.Input, args: [{ isSignal: true, alias: "cluster", required: false }] }] } });
377
505
 
378
506
  // OlTileLayerComponent
379
507
  class OlTileLayerComponent {
@@ -1,20 +1,237 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { Injectable } from '@angular/core';
3
3
 
4
- // OlMilitaryService
4
+ // @angular-helpers/openlayers/military — service implementation
5
+ /**
6
+ * Meters per degree of latitude on a spherical Earth approximation.
7
+ * Used by the local tangent-plane projection in the geometry helpers.
8
+ */
9
+ const METERS_PER_DEGREE_LAT = 111_320;
10
+ /**
11
+ * Service exposing geometry helpers and MIL-STD-2525 symbology rendering.
12
+ *
13
+ * - `createEllipse`, `createSector`, `createDonut` are **pure math** and
14
+ * have no runtime dependencies beyond the bundled types.
15
+ * - `createMilSymbol` uses the milsymbol library via dynamic ESM import.
16
+ */
5
17
  class OlMilitaryService {
18
+ idCounter = 0;
19
+ mlLoader = null;
20
+ msModule = null;
21
+ // ---------------------------------------------------------------------------
22
+ // Geometry helpers (pure math, no deps)
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Build a `Feature<Polygon>` approximating an ellipse centered at
26
+ * `config.center`. See {@link EllipseConfig} for parameter semantics.
27
+ */
6
28
  createEllipse(config) {
7
- return { id: 'ellipse-1', geometry: { type: 'Polygon', coordinates: [] } };
29
+ const { center, semiMajor, semiMinor, rotation = 0, segments = 64, properties } = config;
30
+ if (semiMajor <= 0 || semiMinor <= 0) {
31
+ throw new RangeError('semiMajor and semiMinor must be positive');
32
+ }
33
+ if (segments < 8) {
34
+ throw new RangeError('segments must be >= 8');
35
+ }
36
+ const cosR = Math.cos(rotation);
37
+ const sinR = Math.sin(rotation);
38
+ const ring = [];
39
+ for (let i = 0; i < segments; i++) {
40
+ const theta = (i / segments) * Math.PI * 2;
41
+ // Ellipse in local axis-aligned frame, then rotated by `rotation`.
42
+ const ax = Math.cos(theta) * semiMajor;
43
+ const ay = Math.sin(theta) * semiMinor;
44
+ const dx = ax * cosR - ay * sinR;
45
+ const dy = ax * sinR + ay * cosR;
46
+ ring.push(this.offsetMetersToLonLat(center, dx, dy));
47
+ }
48
+ ring.push(ring[0]); // close the ring
49
+ return {
50
+ id: this.nextId('ellipse'),
51
+ geometry: { type: 'Polygon', coordinates: [ring] },
52
+ properties,
53
+ };
8
54
  }
55
+ /**
56
+ * Build a `Feature<Polygon>` for a circular sector (pie slice).
57
+ * See {@link SectorConfig} for parameter semantics.
58
+ */
9
59
  createSector(config) {
10
- return { id: 'sector-1', geometry: { type: 'Polygon', coordinates: [] } };
60
+ const { center, radius, startAngle, endAngle, segments = 32, properties } = config;
61
+ if (radius <= 0) {
62
+ throw new RangeError('radius must be positive');
63
+ }
64
+ if (endAngle <= startAngle) {
65
+ throw new RangeError('endAngle must be greater than startAngle');
66
+ }
67
+ if (endAngle - startAngle > Math.PI * 2) {
68
+ throw new RangeError('sector cannot exceed full circle');
69
+ }
70
+ if (segments < 4) {
71
+ throw new RangeError('segments must be >= 4');
72
+ }
73
+ const ring = [center];
74
+ const span = endAngle - startAngle;
75
+ for (let i = 0; i <= segments; i++) {
76
+ const theta = startAngle + (i / segments) * span;
77
+ const dx = Math.cos(theta) * radius;
78
+ const dy = Math.sin(theta) * radius;
79
+ ring.push(this.offsetMetersToLonLat(center, dx, dy));
80
+ }
81
+ ring.push(center); // close back to apex
82
+ return {
83
+ id: this.nextId('sector'),
84
+ geometry: { type: 'Polygon', coordinates: [ring] },
85
+ properties,
86
+ };
11
87
  }
12
- addMilSymbol(config) {
88
+ /**
89
+ * Build a `Feature<Polygon>` for a donut (annular ring). The output has
90
+ * two rings: an outer ring wound counter-clockwise and an inner ring
91
+ * wound clockwise so the GeoJSON right-hand rule renders the hole.
92
+ */
93
+ createDonut(config) {
94
+ const { center, outerRadius, innerRadius, segments = 64, properties } = config;
95
+ if (outerRadius <= 0 || innerRadius <= 0) {
96
+ throw new RangeError('radii must be positive');
97
+ }
98
+ if (outerRadius <= innerRadius) {
99
+ throw new RangeError('outerRadius must be greater than innerRadius');
100
+ }
101
+ if (segments < 8) {
102
+ throw new RangeError('segments must be >= 8');
103
+ }
104
+ const outer = [];
105
+ const inner = [];
106
+ for (let i = 0; i < segments; i++) {
107
+ const theta = (i / segments) * Math.PI * 2;
108
+ const cosT = Math.cos(theta);
109
+ const sinT = Math.sin(theta);
110
+ // Outer ring: CCW (theta increasing)
111
+ outer.push(this.offsetMetersToLonLat(center, cosT * outerRadius, sinT * outerRadius));
112
+ // Inner ring: CW — sample the SAME thetas but we'll reverse the
113
+ // accumulator below so the ring is traversed in the opposite sense.
114
+ inner.push(this.offsetMetersToLonLat(center, cosT * innerRadius, sinT * innerRadius));
115
+ }
116
+ inner.reverse();
117
+ outer.push(outer[0]);
118
+ inner.push(inner[0]);
13
119
  return {
14
- id: `symbol-${config.sidc}`,
15
- geometry: { type: 'Point', coordinates: config.position },
120
+ id: this.nextId('donut'),
121
+ geometry: { type: 'Polygon', coordinates: [outer, inner] },
122
+ properties,
16
123
  };
17
124
  }
125
+ // ---------------------------------------------------------------------------
126
+ // MIL-STD-2525 symbology (lazy `milsymbol` load)
127
+ // ---------------------------------------------------------------------------
128
+ /**
129
+ * Pre-load the optional `milsymbol` peer dependency so subsequent calls
130
+ * to `createMilSymbol` / `createMilSymbolSync` resolve immediately.
131
+ * Idempotent — multiple calls share the same promise.
132
+ */
133
+ preloadMilsymbol() {
134
+ this.assertBrowser();
135
+ if (!this.mlLoader) {
136
+ this.mlLoader = import('milsymbol').then((m) => {
137
+ this.msModule = m;
138
+ return m;
139
+ });
140
+ }
141
+ return this.mlLoader.then(() => {
142
+ // Void return for the public API
143
+ });
144
+ }
145
+ /**
146
+ * Build a MIL-STD-2525 symbol feature asynchronously.
147
+ * Lazy-loads `milsymbol` on the first call.
148
+ */
149
+ async createMilSymbol(config) {
150
+ this.assertBrowser();
151
+ this.assertSidc(config.sidc);
152
+ if (!this.msModule) {
153
+ await this.preloadMilsymbol();
154
+ }
155
+ return this.buildSymbolFeature(config);
156
+ }
157
+ /**
158
+ * Build a MIL-STD-2525 symbol feature synchronously.
159
+ * Throws if `milsymbol` has not been preloaded via `preloadMilsymbol()`
160
+ * or a previous `createMilSymbol()` call.
161
+ */
162
+ createMilSymbolSync(config) {
163
+ this.assertBrowser();
164
+ this.assertSidc(config.sidc);
165
+ if (!this.msModule) {
166
+ throw new Error('milsymbol is not loaded yet. Call preloadMilsymbol() or use the async createMilSymbol().');
167
+ }
168
+ return this.buildSymbolFeature(config);
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Internals
172
+ // ---------------------------------------------------------------------------
173
+ /**
174
+ * Project an `(dx, dy)` meter offset from `center` to lon/lat using a
175
+ * local tangent-plane (equirectangular) approximation. Acceptable for
176
+ * the radii typical in military symbology (<100 km from center).
177
+ */
178
+ offsetMetersToLonLat(center, dx, dy) {
179
+ const [lon, lat] = center;
180
+ const latRad = (lat * Math.PI) / 180;
181
+ const dLat = dy / METERS_PER_DEGREE_LAT;
182
+ const dLon = dx / (METERS_PER_DEGREE_LAT * Math.cos(latRad));
183
+ return [lon + dLon, lat + dLat];
184
+ }
185
+ nextId(kind) {
186
+ return `${kind}-${++this.idCounter}`;
187
+ }
188
+ buildSymbolFeature(config) {
189
+ const { sidc, position, properties, quantity, ...rest } = config;
190
+ // `milsymbol` types `quantity` as a string, but a number is the
191
+ // ergonomic shape; coerce here.
192
+ const milOptions = {
193
+ ...rest,
194
+ ...(quantity !== undefined ? { quantity: String(quantity) } : {}),
195
+ };
196
+ // We asserted this.msModule exists before calling this
197
+ const ms = this.msModule;
198
+ // default import might be wrapped depending on bundler
199
+ const SymbolClass = ms.default?.Symbol || ms.Symbol;
200
+ const symbol = new SymbolClass(sidc, milOptions);
201
+ const style = this.symbolToStyleResult(symbol);
202
+ const mergedProperties = { sidc, ...milOptions, ...properties };
203
+ return {
204
+ id: this.nextId('symbol'),
205
+ geometry: { type: 'Point', coordinates: position },
206
+ properties: mergedProperties,
207
+ style: { icon: { src: style.src, size: style.size, anchor: style.anchor } },
208
+ };
209
+ }
210
+ symbolToStyleResult(symbol) {
211
+ const svg = symbol.asSVG();
212
+ const { width, height } = symbol.getSize();
213
+ const { x: ax, y: ay } = symbol.getAnchor();
214
+ return {
215
+ src: `data:image/svg+xml;base64,${this.encodeBase64Utf8(svg)}`,
216
+ size: [width, height],
217
+ anchor: [ax / width, ay / height],
218
+ };
219
+ }
220
+ encodeBase64Utf8(input) {
221
+ // `btoa` only handles Latin-1; this round-trip preserves non-ASCII
222
+ // characters (e.g. unit designators with accents).
223
+ return btoa(unescape(encodeURIComponent(input)));
224
+ }
225
+ assertSidc(sidc) {
226
+ if (typeof sidc !== 'string' || sidc.length < 10) {
227
+ throw new TypeError('sidc must be a non-empty MIL-STD-2525 SIDC string');
228
+ }
229
+ }
230
+ assertBrowser() {
231
+ if (typeof window === 'undefined') {
232
+ throw new Error('createMilSymbol requires a browser environment');
233
+ }
234
+ }
18
235
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlMilitaryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
19
236
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlMilitaryService });
20
237
  }
@@ -2,6 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { inject, ElementRef, input, output, DestroyRef, effect, ChangeDetectionStrategy, Component, Directive, EnvironmentInjector, ApplicationRef, createComponent, Injectable } from '@angular/core';
3
3
  import Overlay from 'ol/Overlay';
4
4
  import { OlMapService, OlZoneHelper } from '@angular-helpers/openlayers/core';
5
+ import { OlLayerService } from '@angular-helpers/openlayers/layers';
5
6
 
6
7
  // OlPopupComponent — declarative popup with content projection.
7
8
  /**
@@ -446,7 +447,7 @@ function applyOverlayConfig(overlay, config) {
446
447
  }
447
448
 
448
449
  function withOverlays() {
449
- return { kind: 'overlays', providers: [OlPopupService] };
450
+ return { kind: 'overlays', providers: [OlLayerService, OlPopupService] };
450
451
  }
451
452
  function provideOverlays() {
452
453
  return withOverlays();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/openlayers",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Modern Angular wrapper for OpenLayers with modular architecture, standalone components, and hybrid template/programmatic API",
5
5
  "homepage": "https://gaspar1992.github.io/angular-helpers/docs/openlayers",
6
6
  "repository": {
@@ -32,9 +32,13 @@
32
32
  "peerDependencies": {
33
33
  "@angular/common": "^21.0.0",
34
34
  "@angular/core": "^21.0.0",
35
+ "milsymbol": "^3.0.0",
35
36
  "ol": "^10.0.0"
36
37
  },
37
38
  "peerDependenciesMeta": {
39
+ "milsymbol": {
40
+ "optional": true
41
+ },
38
42
  "ol": {
39
43
  "optional": false
40
44
  }
@@ -36,6 +36,22 @@ interface Style {
36
36
  width?: number;
37
37
  };
38
38
  };
39
+ /**
40
+ * Icon style for Point features. When set, renderers should produce an
41
+ * `ol/style/Icon` with the given source. Used by the military symbology
42
+ * helpers to embed `milsymbol`-generated SVGs as data URLs.
43
+ */
44
+ icon?: {
45
+ /** Image source — typically a `data:image/svg+xml;base64,...` URL */
46
+ src: string;
47
+ /** Pixel size `[width, height]` */
48
+ size?: [number, number];
49
+ /**
50
+ * Anchor in fractional coordinates `[x, y]` where `0..1` is the icon
51
+ * extent. `[0.5, 0.5]` centers the icon on the feature.
52
+ */
53
+ anchor?: [number, number];
54
+ };
39
55
  text?: {
40
56
  text?: string;
41
57
  font?: string;
@@ -83,6 +99,7 @@ declare class OlMapComponent {
83
99
  mapDblClick: _angular_core.OutputEmitterRef<MapClickEvent>;
84
100
  mapContainerRef: _angular_core.Signal<ElementRef<HTMLDivElement>>;
85
101
  private map?;
102
+ private resizeObserver?;
86
103
  constructor();
87
104
  private initMap;
88
105
  private destroyMap;
@@ -11,6 +11,12 @@ import { Feature as Feature$1 } from 'ol';
11
11
  type InteractionType = 'select' | 'draw' | 'modify' | 'dragAndDrop';
12
12
  interface InteractionConfig {
13
13
  active?: boolean;
14
+ /**
15
+ * If true, enabling this interaction will automatically disable other active exclusive interactions.
16
+ * Useful for mutually exclusive tools like Draw and Modify.
17
+ * @default true
18
+ */
19
+ exclusive?: boolean;
14
20
  }
15
21
  interface SelectConfig extends InteractionConfig {
16
22
  layers?: string[];
@@ -178,6 +184,7 @@ declare class InteractionStateService {
178
184
  readonly modify$: rxjs.Observable<ModifyEvent>;
179
185
  /**
180
186
  * Adds a managed interaction to the state.
187
+ * If the interaction is marked as exclusive, it disables other exclusive interactions.
181
188
  * @param interaction - The interaction to add
182
189
  */
183
190
  addInteraction(interaction: ManagedInteraction): void;
@@ -1,7 +1,52 @@
1
1
  import * as _angular_core from '@angular/core';
2
- import { Feature, Style, Layer, Extent, OlFeature } from '@angular-helpers/openlayers/core';
2
+ import { Layer, Extent, Feature, Style, OlFeature } from '@angular-helpers/openlayers/core';
3
3
  import BaseLayer from 'ol/layer/Base';
4
4
 
5
+ interface LayerConfig extends Layer {
6
+ type: 'vector' | 'tile' | 'image';
7
+ extent?: Extent;
8
+ minResolution?: number;
9
+ maxResolution?: number;
10
+ }
11
+ interface ClusterConfig {
12
+ /** Enable clustering (default: false) */
13
+ enabled: boolean;
14
+ /** Distance in pixels within which features will be clustered (default: 40) */
15
+ distance?: number;
16
+ /** Minimum distance between clusters (default: 20) */
17
+ minDistance?: number;
18
+ /** Show count badge on cluster (default: true) */
19
+ showCount?: boolean;
20
+ /** Style for individual features when clustering */
21
+ featureStyle?: Style;
22
+ }
23
+ interface VectorLayerConfig extends LayerConfig {
24
+ type: 'vector';
25
+ features?: Feature[];
26
+ style?: Style | ((feature: Feature) => Style);
27
+ cluster?: ClusterConfig;
28
+ }
29
+ interface TileLayerConfig extends LayerConfig {
30
+ type: 'tile';
31
+ source: SourceConfig;
32
+ }
33
+ interface ImageLayerConfig extends LayerConfig {
34
+ type: 'image';
35
+ source: ImageSourceConfig;
36
+ }
37
+ interface SourceConfig {
38
+ type: 'osm' | 'xyz' | 'wms' | 'wmts';
39
+ url?: string;
40
+ attributions?: string | string[];
41
+ params?: Record<string, unknown>;
42
+ }
43
+ interface ImageSourceConfig {
44
+ type: 'wms' | 'static';
45
+ url: string;
46
+ params?: Record<string, unknown>;
47
+ imageExtent?: Extent;
48
+ }
49
+
5
50
  declare class OlVectorLayerComponent {
6
51
  private layerService;
7
52
  private destroyRef;
@@ -11,9 +56,10 @@ declare class OlVectorLayerComponent {
11
56
  opacity: _angular_core.InputSignal<number>;
12
57
  visible: _angular_core.InputSignal<boolean>;
13
58
  style: _angular_core.InputSignal<Style | ((feature: Feature) => Style)>;
59
+ cluster: _angular_core.InputSignal<ClusterConfig>;
14
60
  constructor();
15
61
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlVectorLayerComponent, never>;
16
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlVectorLayerComponent, "ol-vector-layer", never, { "id": { "alias": "id"; "required": true; "isSignal": true; }; "features": { "alias": "features"; "required": false; "isSignal": true; }; "zIndex": { "alias": "zIndex"; "required": false; "isSignal": true; }; "opacity": { "alias": "opacity"; "required": false; "isSignal": true; }; "visible": { "alias": "visible"; "required": false; "isSignal": true; }; "style": { "alias": "style"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
62
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlVectorLayerComponent, "ol-vector-layer", never, { "id": { "alias": "id"; "required": true; "isSignal": true; }; "features": { "alias": "features"; "required": false; "isSignal": true; }; "zIndex": { "alias": "zIndex"; "required": false; "isSignal": true; }; "opacity": { "alias": "opacity"; "required": false; "isSignal": true; }; "visible": { "alias": "visible"; "required": false; "isSignal": true; }; "style": { "alias": "style"; "required": false; "isSignal": true; }; "cluster": { "alias": "cluster"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
17
63
  }
18
64
 
19
65
  declare class OlTileLayerComponent {
@@ -48,38 +94,6 @@ declare class OlImageLayerComponent {
48
94
  static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlImageLayerComponent, "ol-image-layer", never, { "id": { "alias": "id"; "required": true; "isSignal": true; }; "sourceType": { "alias": "sourceType"; "required": true; "isSignal": true; }; "url": { "alias": "url"; "required": true; "isSignal": true; }; "params": { "alias": "params"; "required": false; "isSignal": true; }; "imageExtent": { "alias": "imageExtent"; "required": false; "isSignal": true; }; "zIndex": { "alias": "zIndex"; "required": false; "isSignal": true; }; "opacity": { "alias": "opacity"; "required": false; "isSignal": true; }; "visible": { "alias": "visible"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
49
95
  }
50
96
 
51
- interface LayerConfig extends Layer {
52
- type: 'vector' | 'tile' | 'image';
53
- extent?: Extent;
54
- minResolution?: number;
55
- maxResolution?: number;
56
- }
57
- interface VectorLayerConfig extends LayerConfig {
58
- type: 'vector';
59
- features?: Feature[];
60
- style?: Style | ((feature: Feature) => Style);
61
- }
62
- interface TileLayerConfig extends LayerConfig {
63
- type: 'tile';
64
- source: SourceConfig;
65
- }
66
- interface ImageLayerConfig extends LayerConfig {
67
- type: 'image';
68
- source: ImageSourceConfig;
69
- }
70
- interface SourceConfig {
71
- type: 'osm' | 'xyz' | 'wms' | 'wmts';
72
- url?: string;
73
- attributions?: string | string[];
74
- params?: Record<string, unknown>;
75
- }
76
- interface ImageSourceConfig {
77
- type: 'wms' | 'static';
78
- url: string;
79
- params?: Record<string, unknown>;
80
- imageExtent?: Extent;
81
- }
82
-
83
97
  interface LayerInfo {
84
98
  id: string;
85
99
  type: 'vector' | 'tile' | 'image';
@@ -1,27 +1,181 @@
1
1
  import { Coordinate, Feature, OlFeature } from '@angular-helpers/openlayers/core';
2
2
  import * as i0 from '@angular/core';
3
3
 
4
+ /**
5
+ * Configuration for an ellipse polygon centered at `center`.
6
+ * Coordinates are emitted in EPSG:4326 (lon/lat) using a local tangent-plane
7
+ * approximation; accuracy degrades for radii > ~100 km or near the poles.
8
+ */
4
9
  interface EllipseConfig {
10
+ /** Ellipse center as `[lon, lat]` in EPSG:4326. */
5
11
  center: Coordinate;
12
+ /** Semi-major axis in meters. Must be > 0. */
6
13
  semiMajor: number;
14
+ /** Semi-minor axis in meters. Must be > 0. */
7
15
  semiMinor: number;
16
+ /**
17
+ * Rotation in radians, counter-clockwise from East. Default: 0
18
+ * (semi-major axis points East).
19
+ */
8
20
  rotation?: number;
21
+ /**
22
+ * Number of vertices used to approximate the ellipse. Default: 64.
23
+ * Minimum: 8.
24
+ */
25
+ segments?: number;
26
+ /** Custom feature properties to attach to the output feature. */
27
+ properties?: Record<string, unknown>;
9
28
  }
29
+ /**
30
+ * Configuration for a circular sector (pie-slice) polygon.
31
+ * Same projection caveats as `EllipseConfig`.
32
+ */
10
33
  interface SectorConfig {
34
+ /** Sector apex / center as `[lon, lat]` in EPSG:4326. */
11
35
  center: Coordinate;
36
+ /** Sector radius in meters. Must be > 0. */
12
37
  radius: number;
38
+ /** Start angle in radians (0 = East, CCW positive). */
13
39
  startAngle: number;
40
+ /**
41
+ * End angle in radians. Must satisfy `startAngle < endAngle <= startAngle + 2π`.
42
+ */
14
43
  endAngle: number;
44
+ /**
45
+ * Number of vertices along the arc. Default: 32. Minimum: 4. The output
46
+ * polygon has `segments + 3` vertices (apex + arc + apex closer).
47
+ */
48
+ segments?: number;
49
+ /** Custom feature properties to attach to the output feature. */
50
+ properties?: Record<string, unknown>;
15
51
  }
52
+ /**
53
+ * Configuration for a donut (annular ring) polygon — a disk with a
54
+ * concentric circular hole. Useful for range rings, exclusion zones,
55
+ * and similar GIS military primitives.
56
+ *
57
+ * The output `Feature<Polygon>` has TWO rings: an outer ring (CCW) and
58
+ * an inner ring (CW per the GeoJSON right-hand rule), so renderers that
59
+ * follow the spec fill only the band between the radii.
60
+ */
61
+ interface DonutConfig {
62
+ /** Donut center as `[lon, lat]` in EPSG:4326. */
63
+ center: Coordinate;
64
+ /** Outer radius in meters. Must be > `innerRadius`. */
65
+ outerRadius: number;
66
+ /** Inner radius in meters. Must be > 0 and < `outerRadius`. */
67
+ innerRadius: number;
68
+ /**
69
+ * Number of vertices per ring. Default: 64. Minimum: 8. Both rings use
70
+ * the same segment count.
71
+ */
72
+ segments?: number;
73
+ /** Custom feature properties to attach to the output feature. */
74
+ properties?: Record<string, unknown>;
75
+ }
76
+ /**
77
+ * Subset of `milsymbol`'s `SymbolOptions` exposed by this package.
78
+ * `sidc` is the only required field; everything else is optional and
79
+ * forwarded verbatim to `new Symbol(sidc, options)`.
80
+ */
16
81
  interface MilSymbolConfig {
82
+ /** MIL-STD-2525 SIDC code. Required. */
17
83
  sidc: string;
84
+ /** Symbol position as `[lon, lat]` in EPSG:4326. Required. */
18
85
  position: Coordinate;
86
+ /** Symbol size in pixels. Default (set by `milsymbol`): 30. */
87
+ size?: number;
88
+ /** Mono-color override (e.g. `'#000'`). */
89
+ monoColor?: string;
90
+ /** Outline color. */
91
+ outlineColor?: string;
92
+ /** Icon (interior glyph) color. */
93
+ iconColor?: string;
94
+ /** Additional information field (top of the symbol). */
95
+ additionalInformation?: string;
96
+ /** Staff comments field. */
97
+ staffComments?: string;
98
+ /** Quantity field. */
99
+ quantity?: number;
100
+ /** Unique designation field (unit identifier). */
101
+ uniqueDesignation?: string;
102
+ /** Custom feature properties merged into the output feature's `properties`. */
103
+ properties?: Record<string, unknown>;
104
+ }
105
+ /**
106
+ * Result of resolving a `MilSymbolConfig` against `milsymbol`. Embedded
107
+ * into the output feature's `style.icon` so that `<ol-vector-layer>` can
108
+ * render it as an `ol/style/Icon`.
109
+ */
110
+ interface MilSymbolStyleResult {
111
+ /** `data:image/svg+xml;base64,...` URL produced from `Symbol.asSVG()`. */
112
+ src: string;
113
+ /** Pixel `[width, height]` from `Symbol.getSize()`. */
114
+ size: [number, number];
115
+ /**
116
+ * Anchor in fractional coordinates `[x, y]` (0..1), computed from
117
+ * `Symbol.getAnchor()` divided by the size. `[0.5, 0.5]` centers the
118
+ * icon on the feature.
119
+ */
120
+ anchor: [number, number];
19
121
  }
20
122
 
123
+ /**
124
+ * Service exposing geometry helpers and MIL-STD-2525 symbology rendering.
125
+ *
126
+ * - `createEllipse`, `createSector`, `createDonut` are **pure math** and
127
+ * have no runtime dependencies beyond the bundled types.
128
+ * - `createMilSymbol` uses the milsymbol library via dynamic ESM import.
129
+ */
21
130
  declare class OlMilitaryService {
131
+ private idCounter;
132
+ private mlLoader;
133
+ private msModule;
134
+ /**
135
+ * Build a `Feature<Polygon>` approximating an ellipse centered at
136
+ * `config.center`. See {@link EllipseConfig} for parameter semantics.
137
+ */
22
138
  createEllipse(config: EllipseConfig): Feature;
139
+ /**
140
+ * Build a `Feature<Polygon>` for a circular sector (pie slice).
141
+ * See {@link SectorConfig} for parameter semantics.
142
+ */
23
143
  createSector(config: SectorConfig): Feature;
24
- addMilSymbol(config: MilSymbolConfig): Feature;
144
+ /**
145
+ * Build a `Feature<Polygon>` for a donut (annular ring). The output has
146
+ * two rings: an outer ring wound counter-clockwise and an inner ring
147
+ * wound clockwise so the GeoJSON right-hand rule renders the hole.
148
+ */
149
+ createDonut(config: DonutConfig): Feature;
150
+ /**
151
+ * Pre-load the optional `milsymbol` peer dependency so subsequent calls
152
+ * to `createMilSymbol` / `createMilSymbolSync` resolve immediately.
153
+ * Idempotent — multiple calls share the same promise.
154
+ */
155
+ preloadMilsymbol(): Promise<void>;
156
+ /**
157
+ * Build a MIL-STD-2525 symbol feature asynchronously.
158
+ * Lazy-loads `milsymbol` on the first call.
159
+ */
160
+ createMilSymbol(config: MilSymbolConfig): Promise<Feature>;
161
+ /**
162
+ * Build a MIL-STD-2525 symbol feature synchronously.
163
+ * Throws if `milsymbol` has not been preloaded via `preloadMilsymbol()`
164
+ * or a previous `createMilSymbol()` call.
165
+ */
166
+ createMilSymbolSync(config: MilSymbolConfig): Feature;
167
+ /**
168
+ * Project an `(dx, dy)` meter offset from `center` to lon/lat using a
169
+ * local tangent-plane (equirectangular) approximation. Acceptable for
170
+ * the radii typical in military symbology (<100 km from center).
171
+ */
172
+ private offsetMetersToLonLat;
173
+ private nextId;
174
+ private buildSymbolFeature;
175
+ private symbolToStyleResult;
176
+ private encodeBase64Utf8;
177
+ private assertSidc;
178
+ private assertBrowser;
25
179
  static ɵfac: i0.ɵɵFactoryDeclaration<OlMilitaryService, never>;
26
180
  static ɵprov: i0.ɵɵInjectableDeclaration<OlMilitaryService>;
27
181
  }
@@ -30,4 +184,4 @@ declare function withMilitary(): OlFeature<'military'>;
30
184
  declare function provideMilitary(): OlFeature<'military'>;
31
185
 
32
186
  export { OlMilitaryService, provideMilitary, withMilitary };
33
- export type { EllipseConfig, MilSymbolConfig, SectorConfig };
187
+ export type { DonutConfig, EllipseConfig, MilSymbolConfig, MilSymbolStyleResult, SectorConfig };