@angular-helpers/openlayers 0.5.0 → 0.6.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
@@ -45,7 +45,7 @@ export const appConfig: ApplicationConfig = {
45
45
 
46
46
  ### 2. Use in your component
47
47
 
48
- ```typescript
48
+ ````typescript
49
49
  // map.component.ts
50
50
  import { Component, inject, signal } from '@angular/core';
51
51
  import { OlMapComponent } from '@angular-helpers/openlayers/core';
@@ -103,8 +103,57 @@ export class MapComponent {
103
103
  this.mapService.animateView({ center: [2.2945, 48.8584], zoom: 15, duration: 1000 });
104
104
  }
105
105
  }
106
+ ## Custom Projections & Coordinate Systems
107
+
108
+ Available since `0.4.1` in `@angular-helpers/openlayers/core` and `@angular-helpers/openlayers/layers`.
109
+
110
+ When working with local reference systems (like UTM zones), you can register custom projections globally and pass coordinates in those reference systems directly to `[center]` or `[features]` without manual transforms:
111
+
112
+ ### 1. Register custom projections globally
113
+
114
+ ```typescript
115
+ // app.config.ts
116
+ import { provideOpenLayers } from '@angular-helpers/openlayers/core';
117
+ import { withProjections } from '@angular-helpers/openlayers/core';
118
+ import proj4 from 'proj4';
119
+
120
+ export const appConfig = {
121
+ providers: [
122
+ provideOpenLayers(
123
+ withProjections(proj4, [
124
+ {
125
+ code: 'EPSG:25830', // UTM Zone 30N
126
+ def: '+proj=utm +zone=30 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
127
+ extent: [0, 0, 1000000, 10000000],
128
+ },
129
+ ]),
130
+ ),
131
+ ],
132
+ };
133
+ ````
134
+
135
+ ### 2. Pass local coordinates natively to the map
136
+
137
+ By setting `[coordinateProjection]` to match the custom projection, the component automatically bypasses longitude/latitude conversion, feeding UTM coordinates directly into OpenLayers:
138
+
139
+ ```html
140
+ <ol-map
141
+ [projection]="'EPSG:25830'"
142
+ [coordinateProjection]="'EPSG:25830'"
143
+ [center]="[440291, 4474255]" <!-- Madrid UTM Zone 30 coordinates -->
144
+ [zoom]="12"
145
+ (viewChange)="onViewChange($event)"
146
+ >
147
+ <ol-vector-layer
148
+ id="shapes"
149
+ [features]="utmFeatures()"
150
+ [coordinateProjection]="'EPSG:25830'"
151
+ />
152
+ </ol-map>
106
153
  ```
107
154
 
155
+ ---
156
+
108
157
  ## Overlays — popups and tooltips
109
158
 
110
159
  Available since `0.3.0` from `@angular-helpers/openlayers/overlays`.
@@ -184,87 +233,150 @@ const handle = popups.openComponent({
184
233
 
185
234
  `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`.
186
235
 
187
- ## Military symbology
236
+ ## WebGL Layers — GPU-accelerated rendering
188
237
 
189
- Available since `0.4.0` from `@angular-helpers/openlayers/military`.
238
+ Available since `0.3.0` from `@angular-helpers/openlayers/layers`.
239
+
240
+ WebGL layers render directly on the GPU, making them perfect for extremely heavy tile configurations (with real-time styling expressions) and massive coordinate datasets (10,000+ vector points).
190
241
 
191
- 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.
242
+ ### `<ol-webgl-tile-layer>` Raster style manipulation
243
+
244
+ Renders tile layers (OSM, XYZ, MVT) via WebGL. Supports the dynamic application of WebGL tile styles (raster expressions) for dynamic, GPU-powered adjustments like brightness, contrast, saturation, and gamma.
245
+
246
+ ```html
247
+ <ol-webgl-tile-layer
248
+ id="satellite-webgl"
249
+ source="xyz"
250
+ [url]="'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'"
251
+ [tileStyle]="{
252
+ brightness: 0.1,
253
+ contrast: 0.2,
254
+ saturation: -0.5
255
+ }"
256
+ />
257
+ ```
258
+
259
+ ### `<ol-webgl-vector-layer>` — Smooth massive datasets (10k+ features)
260
+
261
+ Renders points, lines, and polygons using WebGL 2. For peak performance, hit detection is disabled by default, and styling must be declared using `FlatStyleLike` expressions rather than standard `ol/style/Style` instances.
262
+
263
+ ```html
264
+ <ol-webgl-vector-layer
265
+ id="massive-points"
266
+ [features]="densePoints()"
267
+ [flatStyle]="{
268
+ 'circle-radius': 6,
269
+ 'circle-fill-color': '#10b981',
270
+ 'stroke-color': '#334155',
271
+ 'stroke-width': 1
272
+ }"
273
+ [disableHitDetection]="true"
274
+ />
275
+ ```
276
+
277
+ Rigorous cleanup guarantees that WebGL contexts, framebuffers, and active buffers are fully released on destroy (`layer.dispose()`), preventing GPU leaks.
278
+
279
+ ## Geodesic Geometry Helpers
280
+
281
+ Available from `@angular-helpers/openlayers/core` via `OlGeometryService`.
282
+
283
+ Approximates standard shapes in metric space using true geodesic calculations (`Vincenty`'s formulae via `ol/sphere`). This means your shapes remain mathematically accurate and visually consistent (without map projection scale distortion) across massive global distances.
192
284
 
193
285
  ```typescript
194
- import { inject, signal } from '@angular/core';
195
- import { OlMilitaryService } from '@angular-helpers/openlayers/military';
286
+ import { inject, Component, signal } from '@angular/core';
287
+ import { OlGeometryService } from '@angular-helpers/openlayers/core';
196
288
  import type { Feature } from '@angular-helpers/openlayers/core';
197
289
 
198
290
  @Component({
199
- // …
200
291
  imports: [OlMapComponent, OlVectorLayerComponent],
201
292
  template: `
202
293
  <ol-map [center]="[2.17, 41.38]" [zoom]="8">
203
294
  <ol-tile-layer id="osm" source="osm" />
204
- <ol-vector-layer id="military" [features]="features()" [zIndex]="10" />
295
+ <ol-vector-layer id="shapes" [features]="features()" />
205
296
  </ol-map>
206
297
  `,
207
298
  })
208
- export class MilDemo {
209
- private ml = inject(OlMilitaryService);
299
+ export class GeodesicDemo {
300
+ private geomSvc = inject(OlGeometryService);
210
301
  features = signal<Feature[]>([]);
211
302
 
212
- async ngOnInit() {
213
- const ellipse = this.ml.createEllipse({
303
+ ngOnInit() {
304
+ const ellipse = this.geomSvc.createEllipse({
214
305
  center: [2.17, 41.38],
215
306
  semiMajor: 6_000,
216
307
  semiMinor: 3_000,
217
308
  rotation: Math.PI / 6,
218
309
  });
219
- const sector = this.ml.createSector({
310
+ const sector = this.geomSvc.createSector({
220
311
  center: [-0.38, 39.47],
221
312
  radius: 8_000,
222
313
  startAngle: Math.PI / 6,
223
314
  endAngle: Math.PI / 2,
224
315
  });
225
- const donut = this.ml.createDonut({
316
+ const donut = this.geomSvc.createDonut({
226
317
  center: [-5.99, 37.39],
227
318
  innerRadius: 5_000,
228
319
  outerRadius: 10_000,
229
320
  });
230
- const symbol = await this.ml.createMilSymbol({
231
- sidc: 'SFGPUCI-----',
232
- position: [-3.7, 40.42],
233
- size: 36,
234
- });
235
- this.features.set([ellipse, sector, donut, symbol]);
321
+ this.features.set([ellipse, sector, donut]);
236
322
  }
237
323
  }
238
324
  ```
239
325
 
240
- ### Geometry helpers
241
-
242
326
  | Method | Output | Notes |
243
327
  | ----------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
244
328
  | `createEllipse(config)` | `Feature<Polygon>` | Optional `rotation` in radians, configurable `segments` (default 64) |
245
329
  | `createSector(config)` | `Feature<Polygon>` | Pie-slice (apex-arc-apex). `startAngle < endAngle ≤ start + 2π` |
246
330
  | `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 |
247
331
 
248
- Coordinates are emitted in `EPSG:4326` (lon/lat) using true geodesic calculations (`Vincenty`'s formulae via `ol/sphere`). This means your shapes remain mathematically accurate and visually consistent (without scale distortion) across massive global distances, fulfilling military precision requirements.
332
+ ---
249
333
 
250
- ### MIL-STD-2525 symbols
334
+ ## Military Symbology & Tactical Graphics
251
335
 
252
- `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:
336
+ Available since `0.4.0` from `@angular-helpers/openlayers/military`.
253
337
 
254
- ```bash
255
- pnpm add milsymbol
338
+ Exposes NATO MIL-STD-2525 symbol rendering backed by the optional [`milsymbol`](https://github.com/spatialillusions/milsymbol) peer dependency, plus tactical military graphic components (frontlines, attack vectors).
339
+
340
+ ### MIL-STD-2525 Point Symbology (`OlMilitaryService`)
341
+
342
+ Lazy-loads the heavy `milsymbol` package dynamically on demand, returning a styled `Feature<Point>` so the vector layer renders it natively.
343
+
344
+ ```typescript
345
+ import { inject, Component, signal } from '@angular/core';
346
+ import { OlMilitaryService } from '@angular-helpers/openlayers/military';
347
+ import type { Feature } from '@angular-helpers/openlayers/core';
348
+
349
+ @Component({
350
+ imports: [OlMapComponent, OlVectorLayerComponent],
351
+ providers: [OlMilitaryService],
352
+ template: `
353
+ <ol-map [center]="[-3.7, 40.42]" [zoom]="8">
354
+ <ol-vector-layer id="military" [features]="features()" />
355
+ </ol-map>
356
+ `,
357
+ })
358
+ export class MilDemo {
359
+ private milSvc = inject(OlMilitaryService);
360
+ features = signal<Feature[]>([]);
361
+
362
+ async ngOnInit() {
363
+ const symbol = await this.milSvc.createMilSymbol({
364
+ sidc: 'SFGPUCI-----', // Friendly Infantry Unit
365
+ position: [-3.7, 40.42],
366
+ size: 36,
367
+ });
368
+ this.features.set([symbol]);
369
+ }
370
+ }
256
371
  ```
257
372
 
258
- ```ts
259
- const symbol = await ml.createMilSymbol({
260
- sidc: 'SFGPUCI-----', // friendly infantry, ground unit
261
- position: [-3.7, 40.42],
262
- size: 36,
263
- uniqueDesignation: 'A1',
264
- });
373
+ Install the optional peer dependency if utilizing NATO symbology:
374
+
375
+ ```bash
376
+ pnpm add milsymbol
265
377
  ```
266
378
 
267
- Three flavors:
379
+ Three execution strategies:
268
380
 
269
381
  - **`createMilSymbol(config)`** — async; lazy-loads on first call.
270
382
  - **`createMilSymbolSync(config)`** — sync; throws if `milsymbol` is not loaded yet.
@@ -272,6 +384,28 @@ Three flavors:
272
384
 
273
385
  The service throws clearly on non-browser environments (`createMilSymbol` requires `window`).
274
386
 
387
+ ### Tactical Graphics (`OlTacticalGraphicsService`)
388
+
389
+ Builds advanced multi-point military tactical graphic features (frontlines with directional teeth, attack arrow coordinates) and provides custom styles.
390
+
391
+ ```typescript
392
+ import { OlTacticalGraphicsService } from '@angular-helpers/openlayers/military';
393
+
394
+ const tacticalSvc = inject(OlTacticalGraphicsService);
395
+
396
+ // Create a frontline graphic
397
+ const frontline = tacticalSvc.createFrontLine(
398
+ [
399
+ [2.1, 41.3],
400
+ [2.2, 41.4],
401
+ ],
402
+ 'friendly',
403
+ );
404
+
405
+ // Apply specialized style for frontline teeth
406
+ const frontlineStyle = tacticalSvc.createFrontLineStyle('#4f46e5', 'friendly');
407
+ ```
408
+
275
409
  ## Architecture
276
410
 
277
411
  ### Data vs UI Separation
package/core/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @angular-helpers/openlayers/core
2
+
3
+ Core library for Angular bindings to OpenLayers.
4
+ Provides essential models, services, and the base `<ol-map>` component.
5
+
6
+ ## Installation
7
+
8
+ \`\`\`bash
9
+ npm install @angular-helpers/openlayers
10
+ \`\`\`
11
+
12
+ ## Core Services & Components
13
+
14
+ - \`OlMapComponent\`: The root map component that manages the core OpenLayers `Map` instance.
15
+ - \`OlMapService\`: A service to retrieve and manage the map instance across child components.
16
+ - \`OlLayerService\`: Manages map layers dynamically.
17
+ - \`OlZoneHelper\`: Optimizes Angular change detection around OpenLayers events.
18
+
19
+ ## Usage
20
+
21
+ \`\`\`typescript
22
+ import { Component } from '@angular/core';
23
+ import { OlMapComponent } from '@angular-helpers/openlayers';
24
+ import { OlTileLayerComponent } from '@angular-helpers/openlayers/layers';
25
+
26
+ @Component({
27
+ selector: 'app-map-demo',
28
+ imports: [OlMapComponent, OlTileLayerComponent],
29
+ template: \`
30
+ <ol-map [center]="[0, 0]" [zoom]="4" class="map-container">
31
+ <ol-tile-layer source="osm"></ol-tile-layer>
32
+ </ol-map>
33
+ \`,
34
+ styles: [\`
35
+ .map-container {
36
+ width: 100%;
37
+ height: 400px;
38
+ display: block;
39
+ }
40
+ \`]
41
+ })
42
+ export class MapDemoComponent {}
43
+ \`\`\`
@@ -1,9 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, NgZone, Injectable, signal, DestroyRef, input, output, viewChild, afterNextRender, effect, ChangeDetectionStrategy, Component, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER } from '@angular/core';
2
+ import { inject, NgZone, Injectable, signal, DestroyRef, input, output, viewChild, afterNextRender, effect, ChangeDetectionStrategy, Component, computed, resource, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER } from '@angular/core';
3
3
  import OLMap from 'ol/Map';
4
4
  import View from 'ol/View';
5
- import { fromLonLat, toLonLat, get } from 'ol/proj';
5
+ import { transform, get } from 'ol/proj';
6
6
  import { offset } from 'ol/sphere';
7
+ import GeoJSON from 'ol/format/GeoJSON';
8
+ import FeatureClass from 'ol/Feature';
9
+ import { Point, LineString, Polygon } from 'ol/geom';
7
10
  import { register } from 'ol/proj/proj4';
8
11
 
9
12
  // ZoneHelperService - Handles NgZone compatibility for zoneless mode
@@ -161,6 +164,7 @@ class OlMapComponent {
161
164
  zoom = input(0, ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
162
165
  rotation = input(0, ...(ngDevMode ? [{ debugName: "rotation" }] : /* istanbul ignore next */ []));
163
166
  projection = input('EPSG:3857', ...(ngDevMode ? [{ debugName: "projection" }] : /* istanbul ignore next */ []));
167
+ coordinateProjection = input('EPSG:4326', ...(ngDevMode ? [{ debugName: "coordinateProjection" }] : /* istanbul ignore next */ [])); // Dynamic input for coordinate systems
164
168
  viewChange = output();
165
169
  mapClick = output();
166
170
  mapDblClick = output();
@@ -187,11 +191,25 @@ class OlMapComponent {
187
191
  // Cleanup when component is destroyed
188
192
  this.destroyRef.onDestroy(() => this.destroyMap());
189
193
  }
194
+ getProjectedCoordinate(coord) {
195
+ const coordProj = this.coordinateProjection();
196
+ const mapProj = this.projection();
197
+ if (coordProj === mapProj)
198
+ return coord;
199
+ return transform(coord, coordProj, mapProj);
200
+ }
201
+ getExternalCoordinate(coord) {
202
+ const coordProj = this.coordinateProjection();
203
+ const mapProj = this.projection();
204
+ if (coordProj === mapProj)
205
+ return coord;
206
+ return transform(coord, mapProj, coordProj);
207
+ }
190
208
  initMap() {
191
209
  const container = this.mapContainerRef().nativeElement;
192
210
  this.zoneHelper.runOutsideAngular(() => {
193
211
  const view = new View({
194
- center: fromLonLat(this.center(), this.projection()),
212
+ center: this.getProjectedCoordinate(this.center()),
195
213
  zoom: this.zoom(),
196
214
  rotation: this.rotation(),
197
215
  projection: this.projection(),
@@ -219,11 +237,11 @@ class OlMapComponent {
219
237
  });
220
238
  });
221
239
  this.map.on('click', (e) => this.zoneHelper.runInsideAngular(() => this.mapClick.emit({
222
- coordinate: toLonLat(e.coordinate, this.projection()),
240
+ coordinate: this.getExternalCoordinate(e.coordinate),
223
241
  pixel: e.pixel,
224
242
  })));
225
243
  this.map.on('dblclick', (e) => this.zoneHelper.runInsideAngular(() => this.mapDblClick.emit({
226
- coordinate: toLonLat(e.coordinate, this.projection()),
244
+ coordinate: this.getExternalCoordinate(e.coordinate),
227
245
  pixel: e.pixel,
228
246
  })));
229
247
  });
@@ -247,7 +265,7 @@ class OlMapComponent {
247
265
  if (!this.map)
248
266
  return;
249
267
  const view = this.map.getView();
250
- const projectedCenter = fromLonLat(center, this.projection());
268
+ const projectedCenter = this.getProjectedCoordinate(center);
251
269
  const currentCenter = view.getCenter();
252
270
  // Only update if center is significantly different (prevents interfering with animations)
253
271
  if (!currentCenter ||
@@ -280,23 +298,23 @@ class OlMapComponent {
280
298
  const view = this.map?.getView();
281
299
  if (view) {
282
300
  const projectedCenter = view.getCenter() ?? [0, 0];
283
- const lonLatCenter = toLonLat(projectedCenter, this.projection());
301
+ const externalCenter = this.getExternalCoordinate(projectedCenter);
284
302
  this.viewChange.emit({
285
- center: lonLatCenter,
303
+ center: externalCenter,
286
304
  zoom: view.getZoom() ?? 0,
287
305
  rotation: view.getRotation() ?? 0,
288
306
  });
289
307
  }
290
308
  }
291
309
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlMapComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
292
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.13", type: OlMapComponent, isStandalone: true, selector: "ol-map", inputs: { center: { classPropertyName: "center", publicName: "center", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null }, rotation: { classPropertyName: "rotation", publicName: "rotation", isSignal: true, isRequired: false, transformFunction: null }, projection: { classPropertyName: "projection", publicName: "projection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewChange: "viewChange", mapClick: "mapClick", mapDblClick: "mapDblClick" }, viewQueries: [{ propertyName: "mapContainerRef", first: true, predicate: ["mapContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `<div class="ol-map-container" #mapContainer></div>
310
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.13", type: OlMapComponent, isStandalone: true, selector: "ol-map", inputs: { center: { classPropertyName: "center", publicName: "center", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null }, rotation: { classPropertyName: "rotation", publicName: "rotation", isSignal: true, isRequired: false, transformFunction: null }, projection: { classPropertyName: "projection", publicName: "projection", isSignal: true, isRequired: false, transformFunction: null }, coordinateProjection: { classPropertyName: "coordinateProjection", publicName: "coordinateProjection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewChange: "viewChange", mapClick: "mapClick", mapDblClick: "mapDblClick" }, viewQueries: [{ propertyName: "mapContainerRef", first: true, predicate: ["mapContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `<div class="ol-map-container" #mapContainer></div>
293
311
  <ng-content />`, isInline: true, styles: [":host{display:block;width:100%;height:100%;position:relative}.ol-map-container{width:100%;height:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
294
312
  }
295
313
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlMapComponent, decorators: [{
296
314
  type: Component,
297
315
  args: [{ selector: 'ol-map', template: `<div class="ol-map-container" #mapContainer></div>
298
316
  <ng-content />`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;width:100%;height:100%;position:relative}.ol-map-container{width:100%;height:100%}\n"] }]
299
- }], ctorParameters: () => [], propDecorators: { center: [{ type: i0.Input, args: [{ isSignal: true, alias: "center", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }], rotation: [{ type: i0.Input, args: [{ isSignal: true, alias: "rotation", required: false }] }], projection: [{ type: i0.Input, args: [{ isSignal: true, alias: "projection", required: false }] }], viewChange: [{ type: i0.Output, args: ["viewChange"] }], mapClick: [{ type: i0.Output, args: ["mapClick"] }], mapDblClick: [{ type: i0.Output, args: ["mapDblClick"] }], mapContainerRef: [{ type: i0.ViewChild, args: ['mapContainer', { isSignal: true }] }] } });
317
+ }], ctorParameters: () => [], propDecorators: { center: [{ type: i0.Input, args: [{ isSignal: true, alias: "center", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }], rotation: [{ type: i0.Input, args: [{ isSignal: true, alias: "rotation", required: false }] }], projection: [{ type: i0.Input, args: [{ isSignal: true, alias: "projection", required: false }] }], coordinateProjection: [{ type: i0.Input, args: [{ isSignal: true, alias: "coordinateProjection", required: false }] }], viewChange: [{ type: i0.Output, args: ["viewChange"] }], mapClick: [{ type: i0.Output, args: ["mapClick"] }], mapDblClick: [{ type: i0.Output, args: ["mapDblClick"] }], mapContainerRef: [{ type: i0.ViewChild, args: ['mapContainer', { isSignal: true }] }] } });
300
318
 
301
319
  // OlGeometryService — general purpose geometry helpers
302
320
  /**
@@ -430,6 +448,224 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
430
448
  }]
431
449
  }] });
432
450
 
451
+ /**
452
+ * Service for orchestrating time-series animations in OpenLayers.
453
+ * Exposes a reactive currentTime signal that updates outside the Angular zone
454
+ * via requestAnimationFrame, ensuring 60FPS WebGL animations without triggering
455
+ * global change detection.
456
+ */
457
+ class OlTimeService {
458
+ zoneHelper = inject(OlZoneHelper);
459
+ timeSignal = signal(Date.now(), ...(ngDevMode ? [{ debugName: "timeSignal" }] : /* istanbul ignore next */ []));
460
+ playingSignal = signal(false, ...(ngDevMode ? [{ debugName: "playingSignal" }] : /* istanbul ignore next */ []));
461
+ speedSignal = signal(1, ...(ngDevMode ? [{ debugName: "speedSignal" }] : /* istanbul ignore next */ []));
462
+ animationFrameId = null;
463
+ lastTick = 0;
464
+ currentTime = computed(() => this.timeSignal(), ...(ngDevMode ? [{ debugName: "currentTime" }] : /* istanbul ignore next */ []));
465
+ isPlaying = computed(() => this.playingSignal(), ...(ngDevMode ? [{ debugName: "isPlaying" }] : /* istanbul ignore next */ []));
466
+ speed = computed(() => this.speedSignal(), ...(ngDevMode ? [{ debugName: "speed" }] : /* istanbul ignore next */ []));
467
+ /**
468
+ * Sets the current time manually.
469
+ * @param time Epoch timestamp in milliseconds
470
+ */
471
+ setTime(time) {
472
+ this.timeSignal.set(time);
473
+ }
474
+ /**
475
+ * Sets the playback speed multiplier.
476
+ * @param speed Multiplier (e.g. 1 = real time, 60 = 1 minute per second)
477
+ */
478
+ setSpeed(speed) {
479
+ this.speedSignal.set(speed);
480
+ }
481
+ /**
482
+ * Starts the time animation loop.
483
+ */
484
+ play() {
485
+ if (this.playingSignal())
486
+ return;
487
+ this.playingSignal.set(true);
488
+ this.lastTick = performance.now();
489
+ this.zoneHelper.runOutsideAngular(() => {
490
+ const loop = (now) => {
491
+ if (!this.playingSignal())
492
+ return;
493
+ const delta = now - this.lastTick;
494
+ this.lastTick = now;
495
+ // Advance time based on delta and speed multiplier
496
+ const advance = delta * this.speedSignal();
497
+ this.timeSignal.update((t) => t + advance);
498
+ this.animationFrameId = requestAnimationFrame(loop);
499
+ };
500
+ this.animationFrameId = requestAnimationFrame(loop);
501
+ });
502
+ }
503
+ /**
504
+ * Pauses the time animation loop.
505
+ */
506
+ pause() {
507
+ this.playingSignal.set(false);
508
+ if (this.animationFrameId !== null) {
509
+ cancelAnimationFrame(this.animationFrameId);
510
+ this.animationFrameId = null;
511
+ }
512
+ }
513
+ /**
514
+ * Stops the animation and resets to a specific time.
515
+ */
516
+ stop(resetTime = Date.now()) {
517
+ this.pause();
518
+ this.setTime(resetTime);
519
+ }
520
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlTimeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
521
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlTimeService, providedIn: 'root' });
522
+ }
523
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlTimeService, decorators: [{
524
+ type: Injectable,
525
+ args: [{ providedIn: 'root' }]
526
+ }] });
527
+
528
+ // Feature conversion utilities for OpenLayers interactions
529
+ function transformCoords(coords, sourceProj, targetProj) {
530
+ if (!sourceProj || !targetProj || sourceProj === targetProj)
531
+ return coords;
532
+ if (!Array.isArray(coords))
533
+ return coords;
534
+ if (typeof coords[0] === 'number') {
535
+ return transform(coords, sourceProj, targetProj);
536
+ }
537
+ return coords.map((c) => transformCoords(c, sourceProj, targetProj));
538
+ }
539
+ /**
540
+ * Converts an OpenLayers feature to the internal Feature format.
541
+ * Handles coordinate extraction and geometry type mapping with custom projections.
542
+ *
543
+ * @param olFeature - The OpenLayers feature to convert
544
+ * @param options - Projection source and target codes
545
+ * @returns The converted Feature with normalized structure
546
+ */
547
+ function olFeatureToFeature(olFeature, options) {
548
+ // Unwrap spider features
549
+ const spiderFeature = olFeature.get('spider-feature');
550
+ if (spiderFeature) {
551
+ return olFeatureToFeature(spiderFeature, options);
552
+ }
553
+ // Unwrap single-item clusters
554
+ const clusterFeatures = olFeature.get('features');
555
+ if (Array.isArray(clusterFeatures) && clusterFeatures.length === 1) {
556
+ return olFeatureToFeature(clusterFeatures[0], options);
557
+ }
558
+ const geometry = olFeature.getGeometry();
559
+ const geomType = geometry?.getType() ?? 'Point';
560
+ const sourceProj = options?.targetProjection;
561
+ const targetProj = options?.sourceProjection;
562
+ // Convert coordinates based on geometry type
563
+ let coordinates;
564
+ if (geomType === 'Circle') {
565
+ // ol/geom/Circle has no getCoordinates() — use getCenter() instead
566
+ const circle = geometry;
567
+ coordinates = transformCoords(circle.getCenter(), sourceProj, targetProj);
568
+ }
569
+ else {
570
+ // oxlint-disable-next-line no-explicit-any
571
+ const olCoords = geometry.getCoordinates();
572
+ coordinates = transformCoords(olCoords, sourceProj, targetProj);
573
+ }
574
+ return {
575
+ id: olFeature.getId()?.toString() ?? `feature-${Math.random().toString(36).slice(2)}`,
576
+ geometry: {
577
+ type: geomType,
578
+ coordinates,
579
+ },
580
+ properties: olFeature.getProperties(),
581
+ };
582
+ }
583
+ /**
584
+ * Converts an internal Feature to an OpenLayers feature.
585
+ */
586
+ function featureToOlFeature(feature, options) {
587
+ const sourceProj = options?.sourceProjection ?? 'EPSG:4326';
588
+ const targetProj = options?.targetProjection ?? 'EPSG:3857';
589
+ const geom = feature.geometry;
590
+ let geometry;
591
+ if (!geom.coordinates) {
592
+ geometry = new Point([0, 0]);
593
+ }
594
+ else if (geom.type === 'Point') {
595
+ const coords = geom.coordinates;
596
+ geometry = new Point(transformCoords(coords, sourceProj, targetProj));
597
+ }
598
+ else if (geom.type === 'LineString') {
599
+ const coords = transformCoords(geom.coordinates, sourceProj, targetProj);
600
+ geometry = new LineString(coords);
601
+ }
602
+ else if (geom.type === 'Polygon') {
603
+ const rings = transformCoords(geom.coordinates, sourceProj, targetProj);
604
+ geometry = new Polygon(rings);
605
+ }
606
+ else {
607
+ geometry = new Point([0, 0]);
608
+ }
609
+ // Create OL Feature
610
+ // Note: we must avoid passing 'geometry' as a plain object to OLFeature constructor,
611
+ // so we pass the object properties without it, then set geometry explicitly.
612
+ const props = { ...feature.properties };
613
+ delete props['geometry']; // Just in case
614
+ const olFeature = new FeatureClass(props);
615
+ olFeature.setGeometry(geometry);
616
+ olFeature.setId(feature.id);
617
+ return olFeature;
618
+ }
619
+
620
+ /**
621
+ * Creates an Angular resource for fetching and decoding GeoJSON into OpenLayers Features.
622
+ * Must be called in an injection context.
623
+ *
624
+ * @param url The URL signal or string to fetch data from
625
+ * @param options Additional vector resource options
626
+ * @returns An Angular Resource containing an array of parsed Features
627
+ */
628
+ function createVectorResource(url, options) {
629
+ return resource({
630
+ loader: async ({ abortSignal }) => {
631
+ const fetchUrl = url();
632
+ if (!fetchUrl)
633
+ return [];
634
+ const cacheKey = 'ol-vector-cache-v1';
635
+ const isBrowser = typeof caches !== 'undefined';
636
+ let response;
637
+ let cache;
638
+ if (isBrowser) {
639
+ cache = await caches.open(cacheKey);
640
+ const cachedResponse = await cache.match(fetchUrl);
641
+ if (cachedResponse) {
642
+ response = cachedResponse;
643
+ }
644
+ }
645
+ if (!response) {
646
+ response = await fetch(fetchUrl, {
647
+ ...options?.fetchOptions,
648
+ signal: abortSignal,
649
+ });
650
+ if (!response.ok) {
651
+ throw new Error(`Failed to fetch vector data: ${response.statusText}`);
652
+ }
653
+ if (isBrowser && cache) {
654
+ await cache.put(fetchUrl, response.clone());
655
+ }
656
+ }
657
+ // We process the text since GeoJSON readFeatures accepts string or object.
658
+ // Doing text() might be slightly faster before passing to OL's parser.
659
+ const data = await response.text();
660
+ const format = new GeoJSON();
661
+ // We parse the GeoJSON string into OpenLayers features.
662
+ const olFeatures = format.readFeatures(data);
663
+ // Convert to our generic Feature interface expected by OlVectorLayer
664
+ return olFeatures.map((f) => olFeatureToFeature(f));
665
+ },
666
+ });
667
+ }
668
+
433
669
  // Provider functions
434
670
  function provideOpenLayers(...features) {
435
671
  return makeEnvironmentProviders([
@@ -478,4 +714,4 @@ function withProjections(proj4, definitions) {
478
714
  * Generated bundle index. Do not edit.
479
715
  */
480
716
 
481
- export { OlGeometryService, OlMapComponent, OlMapService, OlZoneHelper, provideOpenLayers, withProjections };
717
+ export { OlGeometryService, OlMapComponent, OlMapService, OlTimeService, OlZoneHelper, createVectorResource, featureToOlFeature, olFeatureToFeature, provideOpenLayers, withProjections };