@angular-helpers/openlayers 0.5.1 → 0.7.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`.
@@ -227,6 +276,68 @@ Renders points, lines, and polygons using WebGL 2. For peak performance, hit det
227
276
 
228
277
  Rigorous cleanup guarantees that WebGL contexts, framebuffers, and active buffers are fully released on destroy (`layer.dispose()`), preventing GPU leaks.
229
278
 
279
+ ## Time-Series Animation & Playback
280
+
281
+ Available since `0.5.0` from `@angular-helpers/openlayers/core` and `@angular-helpers/openlayers/controls`.
282
+
283
+ Provides high-performance, GPU-friendly reactive animation controls for time-series geospatial visualization (e.g. weather radar, vehicle tracking, historical paths).
284
+
285
+ ### 1. `OlTimeService` — 60FPS Animation Loop
286
+
287
+ The core service coordinates playback timing. It runs its timing ticks entirely outside the Angular zone (using `requestAnimationFrame`) to prevent triggering global Angular change detection cycles at 60FPS, while exposing reactive signals for component consumption.
288
+
289
+ ```typescript
290
+ import { inject, Component } from '@angular/core';
291
+ import { OlTimeService } from '@angular-helpers/openlayers/core';
292
+
293
+ @Component({
294
+ template: `<p>Current Time: {{ timeSvc.currentTime() }}</p>`,
295
+ })
296
+ export class MapAnimation {
297
+ protected timeSvc = inject(OlTimeService);
298
+
299
+ constructor() {
300
+ // Start animation loop
301
+ this.timeSvc.play();
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### 2. `<ol-timeline>` — Premium Playback UI Control
307
+
308
+ A sleek, glassmorphic UI overlay component displaying a timeline scrubber, play/pause toggle, and playback speed multiplier.
309
+
310
+ ```html
311
+ <ol-map [center]="[2.17, 41.38]" [zoom]="12">
312
+ <ol-tile-layer source="osm" />
313
+
314
+ <ol-timeline
315
+ [startTime]="1700000000000"
316
+ [endTime]="1700086400000"
317
+ [playSpeed]="60"
318
+ [loop]="true"
319
+ position="bottom-center"
320
+ (timeChange)="onTimeChange($event)"
321
+ />
322
+ </ol-map>
323
+ ```
324
+
325
+ | Input | Type | Default | Description |
326
+ | ------------- | -------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------- |
327
+ | `startTime` | `number` | _Required_ | Start bounds of the time-series (Epoch ms) |
328
+ | `endTime` | `number` | _Required_ | End bounds of the time-series (Epoch ms) |
329
+ | `playSpeed` | `number` | `1` | Default speed multiplier (e.g. 1, 5, 10, 60, 3600) |
330
+ | `loop` | `boolean` | `false` | Loop playback when reaching `endTime` |
331
+ | `position` | `TimelinePosition` | `'bottom-center'` | Control alignment (`top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`, `bottom-right`) |
332
+ | `formatLabel` | `(time: number) => string` | `new Date(t).toLocaleString()` | Custom label formatter for the time display |
333
+
334
+ | Output | Type | Description |
335
+ | ----------------- | --------- | --------------------------------------------------------------------------------- |
336
+ | `timeChange` | `number` | Emitted with the current active epoch timestamp when the timeline ticks or scrubs |
337
+ | `playStateChange` | `boolean` | Emitted when playback transitions between playing (`true`) and paused (`false`) |
338
+
339
+ ---
340
+
230
341
  ## Geodesic Geometry Helpers
231
342
 
232
343
  Available from `@angular-helpers/openlayers/core` via `OlGeometryService`.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, input, DestroyRef, afterNextRender, ChangeDetectionStrategy, Component, InjectionToken, output, signal, ViewChild, Injectable } from '@angular/core';
3
- import { OlMapService, OlZoneHelper } from '@angular-helpers/openlayers/core';
2
+ import { inject, input, DestroyRef, afterNextRender, ChangeDetectionStrategy, Component, InjectionToken, output, signal, ViewChild, Injectable, computed, effect } from '@angular/core';
3
+ import { OlMapService, OlZoneHelper, OlTimeService } from '@angular-helpers/openlayers/core';
4
4
  import Zoom from 'ol/control/Zoom';
5
5
  import Attribution from 'ol/control/Attribution';
6
6
  import ScaleLine from 'ol/control/ScaleLine';
@@ -857,10 +857,240 @@ function provideControls() {
857
857
  return withControls();
858
858
  }
859
859
 
860
+ /**
861
+ * A premium, reactive visual timeline component for OpenLayers.
862
+ * Orchestrates time-series animations by binding directly to OlTimeService.
863
+ *
864
+ * Uses native controls, CSS grid/flex layouts, and a sleek glassmorphic theme.
865
+ * Completely free of CommonModule or FormsModule dependencies for maximum performance.
866
+ *
867
+ * @usageNotes
868
+ * ```html
869
+ * <ol-timeline
870
+ * [startTime]="1700000000000"
871
+ * [endTime]="1700086400000"
872
+ * [playSpeed]="60"
873
+ * [loop]="true"
874
+ * position="bottom-center">
875
+ * </ol-timeline>
876
+ * ```
877
+ */
878
+ class OlTimelineComponent {
879
+ timeService = inject(OlTimeService);
880
+ /** Start bounds of time-series (Epoch ms) */
881
+ startTime = input.required(...(ngDevMode ? [{ debugName: "startTime" }] : /* istanbul ignore next */ []));
882
+ /** End bounds of time-series (Epoch ms) */
883
+ endTime = input.required(...(ngDevMode ? [{ debugName: "endTime" }] : /* istanbul ignore next */ []));
884
+ /** Default speed multiplier (e.g. 1, 5, 10, 60, 3600) */
885
+ playSpeed = input(1, ...(ngDevMode ? [{ debugName: "playSpeed" }] : /* istanbul ignore next */ []));
886
+ /** Loop playback when reaching endTime */
887
+ loop = input(false, ...(ngDevMode ? [{ debugName: "loop" }] : /* istanbul ignore next */ []));
888
+ /** Position overlay alignment */
889
+ position = input('bottom-center', ...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
890
+ /** Custom label formatter */
891
+ formatLabel = input((t) => new Date(t).toLocaleString(), ...(ngDevMode ? [{ debugName: "formatLabel" }] : /* istanbul ignore next */ []));
892
+ /** Outputs */
893
+ timeChange = output();
894
+ playStateChange = output();
895
+ /** Computeds binding directly to OlTimeService */
896
+ currentTime = computed(() => this.timeService.currentTime(), ...(ngDevMode ? [{ debugName: "currentTime" }] : /* istanbul ignore next */ []));
897
+ isPlaying = computed(() => this.timeService.isPlaying(), ...(ngDevMode ? [{ debugName: "isPlaying" }] : /* istanbul ignore next */ []));
898
+ speed = computed(() => this.timeService.speed(), ...(ngDevMode ? [{ debugName: "speed" }] : /* istanbul ignore next */ []));
899
+ formattedTime = computed(() => this.formatLabel()(this.currentTime()), ...(ngDevMode ? [{ debugName: "formattedTime" }] : /* istanbul ignore next */ []));
900
+ constructor() {
901
+ // Sync default configuration settings when the inputs initialize
902
+ effect(() => {
903
+ this.timeService.setSpeed(this.playSpeed());
904
+ });
905
+ // Make sure initial time starts at the startTime bounds
906
+ effect(() => {
907
+ this.timeService.setTime(this.startTime());
908
+ });
909
+ // Reactive time-based loop boundaries check
910
+ effect(() => {
911
+ const current = this.currentTime();
912
+ const end = this.endTime();
913
+ const isAnimPlaying = this.isPlaying();
914
+ if (current >= end && isAnimPlaying) {
915
+ if (this.loop()) {
916
+ this.timeService.setTime(this.startTime());
917
+ }
918
+ else {
919
+ this.timeService.pause();
920
+ this.timeService.setTime(end);
921
+ this.playStateChange.emit(false);
922
+ }
923
+ }
924
+ });
925
+ // Emit reactive output when current time advances
926
+ effect(() => {
927
+ this.timeChange.emit(this.currentTime());
928
+ });
929
+ }
930
+ togglePlay() {
931
+ if (this.isPlaying()) {
932
+ this.timeService.pause();
933
+ this.playStateChange.emit(false);
934
+ }
935
+ else {
936
+ // If we are at the end, reset to start before playing
937
+ if (this.currentTime() >= this.endTime()) {
938
+ this.timeService.setTime(this.startTime());
939
+ }
940
+ this.timeService.play();
941
+ this.playStateChange.emit(true);
942
+ }
943
+ }
944
+ onScrub(event) {
945
+ const target = event.target;
946
+ const value = Number(target.value);
947
+ this.timeService.setTime(value);
948
+ }
949
+ onSpeedChange(event) {
950
+ const target = event.target;
951
+ const value = Number(target.value);
952
+ this.timeService.setSpeed(value);
953
+ }
954
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlTimelineComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
955
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: OlTimelineComponent, isStandalone: true, selector: "ol-timeline", inputs: { startTime: { classPropertyName: "startTime", publicName: "startTime", isSignal: true, isRequired: true, transformFunction: null }, endTime: { classPropertyName: "endTime", publicName: "endTime", isSignal: true, isRequired: true, transformFunction: null }, playSpeed: { classPropertyName: "playSpeed", publicName: "playSpeed", isSignal: true, isRequired: false, transformFunction: null }, loop: { classPropertyName: "loop", publicName: "loop", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, formatLabel: { classPropertyName: "formatLabel", publicName: "formatLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { timeChange: "timeChange", playStateChange: "playStateChange" }, ngImport: i0, template: `
956
+ <div
957
+ class="ol-timeline"
958
+ [class.ol-timeline--top-left]="position() === 'top-left'"
959
+ [class.ol-timeline--top-center]="position() === 'top-center'"
960
+ [class.ol-timeline--top-right]="position() === 'top-right'"
961
+ [class.ol-timeline--bottom-left]="position() === 'bottom-left'"
962
+ [class.ol-timeline--bottom-center]="position() === 'bottom-center'"
963
+ [class.ol-timeline--bottom-right]="position() === 'bottom-right'"
964
+ >
965
+ <div class="ol-timeline__controls">
966
+ <button
967
+ type="button"
968
+ class="ol-timeline__btn ol-timeline__btn--play"
969
+ (click)="togglePlay()"
970
+ [attr.aria-label]="isPlaying() ? 'Pause animation' : 'Play animation'"
971
+ >
972
+ @if (isPlaying()) {
973
+ <!-- Pause Icon -->
974
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
975
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
976
+ </svg>
977
+ } @else {
978
+ <!-- Play Icon -->
979
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
980
+ <path d="M8 5v14l11-7z" />
981
+ </svg>
982
+ }
983
+ </button>
984
+
985
+ <span class="ol-timeline__time-display" aria-live="polite">
986
+ {{ formattedTime() }}
987
+ </span>
988
+ </div>
989
+
990
+ <div class="ol-timeline__slider-container">
991
+ <input
992
+ type="range"
993
+ class="ol-timeline__slider"
994
+ [min]="startTime()"
995
+ [max]="endTime()"
996
+ [value]="currentTime()"
997
+ (input)="onScrub($event)"
998
+ aria-label="Timeline progress slider"
999
+ />
1000
+ </div>
1001
+
1002
+ <div class="ol-timeline__settings">
1003
+ <select
1004
+ class="ol-timeline__speed-select"
1005
+ [value]="speed()"
1006
+ (change)="onSpeedChange($event)"
1007
+ aria-label="Playback speed multiplier"
1008
+ >
1009
+ <option [value]="1">1x (Real)</option>
1010
+ <option [value]="5">5x</option>
1011
+ <option [value]="10">10x</option>
1012
+ <option [value]="30">30x</option>
1013
+ <option [value]="60">60x (1m/s)</option>
1014
+ <option [value]="300">300x (5m/s)</option>
1015
+ <option [value]="3600">3600x (1h/s)</option>
1016
+ </select>
1017
+ </div>
1018
+ </div>
1019
+ `, isInline: true, styles: [":host{display:block}.ol-timeline{position:absolute;display:flex;align-items:center;gap:16px;padding:8px 16px;background:#1e1e1ebf;backdrop-filter:blur(12px) saturate(160%);-webkit-backdrop-filter:blur(12px) saturate(160%);border:1px solid rgba(255,255,255,.08);border-radius:24px;box-shadow:0 8px 32px #0000004d;color:#f3f3f3;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:13px;z-index:10;width:calc(100% - 32px);max-width:600px;box-sizing:border-box}.ol-timeline--top-left{top:12px;left:12px}.ol-timeline--top-center{top:12px;left:50%;transform:translate(-50%)}.ol-timeline--top-right{top:12px;right:12px}.ol-timeline--bottom-left{bottom:12px;left:12px}.ol-timeline--bottom-center{bottom:24px;left:50%;transform:translate(-50%)}.ol-timeline--bottom-right{bottom:12px;right:12px}.ol-timeline__controls{display:flex;align-items:center;gap:12px;flex-shrink:0}.ol-timeline__btn{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border:none;border-radius:50%;background:#ffffff1a;color:#fff;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__btn:hover{background:#fff3;transform:scale(1.05)}.ol-timeline__btn:active{transform:scale(.95)}.ol-timeline__time-display{font-variant-numeric:tabular-nums;font-weight:500;color:#e0e0e0;min-width:140px;text-align:center}.ol-timeline__slider-container{flex-grow:1;display:flex;align-items:center}.ol-timeline__slider{-webkit-appearance:none;width:100%;height:4px;border-radius:2px;background:#fff3;outline:none;cursor:pointer;transition:background .15s ease}.ol-timeline__slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#1a73e8;box-shadow:0 1px 4px #0006;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__slider::-webkit-slider-thumb:hover{background:#2b84f0;transform:scale(1.15)}.ol-timeline__slider::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:#1a73e8;box-shadow:0 1px 4px #0006;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__slider::-moz-range-thumb:hover{background:#2b84f0;transform:scale(1.15)}.ol-timeline__settings{flex-shrink:0}.ol-timeline__speed-select{background:#ffffff1a;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:4px 8px;font-size:11px;font-weight:500;outline:none;cursor:pointer;transition:background .15s ease}.ol-timeline__speed-select:hover{background:#ffffff26}.ol-timeline__speed-select option{background:#1e1e1e;color:#fff}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1020
+ }
1021
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlTimelineComponent, decorators: [{
1022
+ type: Component,
1023
+ args: [{ selector: 'ol-timeline', standalone: true, imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1024
+ <div
1025
+ class="ol-timeline"
1026
+ [class.ol-timeline--top-left]="position() === 'top-left'"
1027
+ [class.ol-timeline--top-center]="position() === 'top-center'"
1028
+ [class.ol-timeline--top-right]="position() === 'top-right'"
1029
+ [class.ol-timeline--bottom-left]="position() === 'bottom-left'"
1030
+ [class.ol-timeline--bottom-center]="position() === 'bottom-center'"
1031
+ [class.ol-timeline--bottom-right]="position() === 'bottom-right'"
1032
+ >
1033
+ <div class="ol-timeline__controls">
1034
+ <button
1035
+ type="button"
1036
+ class="ol-timeline__btn ol-timeline__btn--play"
1037
+ (click)="togglePlay()"
1038
+ [attr.aria-label]="isPlaying() ? 'Pause animation' : 'Play animation'"
1039
+ >
1040
+ @if (isPlaying()) {
1041
+ <!-- Pause Icon -->
1042
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
1043
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
1044
+ </svg>
1045
+ } @else {
1046
+ <!-- Play Icon -->
1047
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
1048
+ <path d="M8 5v14l11-7z" />
1049
+ </svg>
1050
+ }
1051
+ </button>
1052
+
1053
+ <span class="ol-timeline__time-display" aria-live="polite">
1054
+ {{ formattedTime() }}
1055
+ </span>
1056
+ </div>
1057
+
1058
+ <div class="ol-timeline__slider-container">
1059
+ <input
1060
+ type="range"
1061
+ class="ol-timeline__slider"
1062
+ [min]="startTime()"
1063
+ [max]="endTime()"
1064
+ [value]="currentTime()"
1065
+ (input)="onScrub($event)"
1066
+ aria-label="Timeline progress slider"
1067
+ />
1068
+ </div>
1069
+
1070
+ <div class="ol-timeline__settings">
1071
+ <select
1072
+ class="ol-timeline__speed-select"
1073
+ [value]="speed()"
1074
+ (change)="onSpeedChange($event)"
1075
+ aria-label="Playback speed multiplier"
1076
+ >
1077
+ <option [value]="1">1x (Real)</option>
1078
+ <option [value]="5">5x</option>
1079
+ <option [value]="10">10x</option>
1080
+ <option [value]="30">30x</option>
1081
+ <option [value]="60">60x (1m/s)</option>
1082
+ <option [value]="300">300x (5m/s)</option>
1083
+ <option [value]="3600">3600x (1h/s)</option>
1084
+ </select>
1085
+ </div>
1086
+ </div>
1087
+ `, styles: [":host{display:block}.ol-timeline{position:absolute;display:flex;align-items:center;gap:16px;padding:8px 16px;background:#1e1e1ebf;backdrop-filter:blur(12px) saturate(160%);-webkit-backdrop-filter:blur(12px) saturate(160%);border:1px solid rgba(255,255,255,.08);border-radius:24px;box-shadow:0 8px 32px #0000004d;color:#f3f3f3;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:13px;z-index:10;width:calc(100% - 32px);max-width:600px;box-sizing:border-box}.ol-timeline--top-left{top:12px;left:12px}.ol-timeline--top-center{top:12px;left:50%;transform:translate(-50%)}.ol-timeline--top-right{top:12px;right:12px}.ol-timeline--bottom-left{bottom:12px;left:12px}.ol-timeline--bottom-center{bottom:24px;left:50%;transform:translate(-50%)}.ol-timeline--bottom-right{bottom:12px;right:12px}.ol-timeline__controls{display:flex;align-items:center;gap:12px;flex-shrink:0}.ol-timeline__btn{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border:none;border-radius:50%;background:#ffffff1a;color:#fff;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__btn:hover{background:#fff3;transform:scale(1.05)}.ol-timeline__btn:active{transform:scale(.95)}.ol-timeline__time-display{font-variant-numeric:tabular-nums;font-weight:500;color:#e0e0e0;min-width:140px;text-align:center}.ol-timeline__slider-container{flex-grow:1;display:flex;align-items:center}.ol-timeline__slider{-webkit-appearance:none;width:100%;height:4px;border-radius:2px;background:#fff3;outline:none;cursor:pointer;transition:background .15s ease}.ol-timeline__slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#1a73e8;box-shadow:0 1px 4px #0006;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__slider::-webkit-slider-thumb:hover{background:#2b84f0;transform:scale(1.15)}.ol-timeline__slider::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:#1a73e8;box-shadow:0 1px 4px #0006;cursor:pointer;transition:background .15s ease,transform .15s ease}.ol-timeline__slider::-moz-range-thumb:hover{background:#2b84f0;transform:scale(1.15)}.ol-timeline__settings{flex-shrink:0}.ol-timeline__speed-select{background:#ffffff1a;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:4px 8px;font-size:11px;font-weight:500;outline:none;cursor:pointer;transition:background .15s ease}.ol-timeline__speed-select:hover{background:#ffffff26}.ol-timeline__speed-select option{background:#1e1e1e;color:#fff}\n"] }]
1088
+ }], ctorParameters: () => [], propDecorators: { startTime: [{ type: i0.Input, args: [{ isSignal: true, alias: "startTime", required: true }] }], endTime: [{ type: i0.Input, args: [{ isSignal: true, alias: "endTime", required: true }] }], playSpeed: [{ type: i0.Input, args: [{ isSignal: true, alias: "playSpeed", required: false }] }], loop: [{ type: i0.Input, args: [{ isSignal: true, alias: "loop", required: false }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }], formatLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "formatLabel", required: false }] }], timeChange: [{ type: i0.Output, args: ["timeChange"] }], playStateChange: [{ type: i0.Output, args: ["playStateChange"] }] } });
1089
+
860
1090
  // @angular-helpers/openlayers/controls
861
1091
 
862
1092
  /**
863
1093
  * Generated bundle index. Do not edit.
864
1094
  */
865
1095
 
866
- export { OlAttributionControlComponent, OlBasemapSwitcherComponent, OlControlService, OlFullscreenControlComponent, OlGeolocationControlComponent, OlLayerSwitcherComponent, OlRotateControlComponent, OlScaleLineControlComponent, OlZoomControlComponent, ROTATE_CONTROL_MAP_SERVICE, provideControls, withControls };
1096
+ export { OlAttributionControlComponent, OlBasemapSwitcherComponent, OlControlService, OlFullscreenControlComponent, OlGeolocationControlComponent, OlLayerSwitcherComponent, OlRotateControlComponent, OlScaleLineControlComponent, OlTimelineComponent, OlZoomControlComponent, ROTATE_CONTROL_MAP_SERVICE, provideControls, withControls };
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
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
7
  import GeoJSON from 'ol/format/GeoJSON';
8
8
  import FeatureClass from 'ol/Feature';
@@ -164,6 +164,7 @@ class OlMapComponent {
164
164
  zoom = input(0, ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
165
165
  rotation = input(0, ...(ngDevMode ? [{ debugName: "rotation" }] : /* istanbul ignore next */ []));
166
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
167
168
  viewChange = output();
168
169
  mapClick = output();
169
170
  mapDblClick = output();
@@ -190,11 +191,25 @@ class OlMapComponent {
190
191
  // Cleanup when component is destroyed
191
192
  this.destroyRef.onDestroy(() => this.destroyMap());
192
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
+ }
193
208
  initMap() {
194
209
  const container = this.mapContainerRef().nativeElement;
195
210
  this.zoneHelper.runOutsideAngular(() => {
196
211
  const view = new View({
197
- center: fromLonLat(this.center(), this.projection()),
212
+ center: this.getProjectedCoordinate(this.center()),
198
213
  zoom: this.zoom(),
199
214
  rotation: this.rotation(),
200
215
  projection: this.projection(),
@@ -222,11 +237,11 @@ class OlMapComponent {
222
237
  });
223
238
  });
224
239
  this.map.on('click', (e) => this.zoneHelper.runInsideAngular(() => this.mapClick.emit({
225
- coordinate: toLonLat(e.coordinate, this.projection()),
240
+ coordinate: this.getExternalCoordinate(e.coordinate),
226
241
  pixel: e.pixel,
227
242
  })));
228
243
  this.map.on('dblclick', (e) => this.zoneHelper.runInsideAngular(() => this.mapDblClick.emit({
229
- coordinate: toLonLat(e.coordinate, this.projection()),
244
+ coordinate: this.getExternalCoordinate(e.coordinate),
230
245
  pixel: e.pixel,
231
246
  })));
232
247
  });
@@ -250,7 +265,7 @@ class OlMapComponent {
250
265
  if (!this.map)
251
266
  return;
252
267
  const view = this.map.getView();
253
- const projectedCenter = fromLonLat(center, this.projection());
268
+ const projectedCenter = this.getProjectedCoordinate(center);
254
269
  const currentCenter = view.getCenter();
255
270
  // Only update if center is significantly different (prevents interfering with animations)
256
271
  if (!currentCenter ||
@@ -283,23 +298,23 @@ class OlMapComponent {
283
298
  const view = this.map?.getView();
284
299
  if (view) {
285
300
  const projectedCenter = view.getCenter() ?? [0, 0];
286
- const lonLatCenter = toLonLat(projectedCenter, this.projection());
301
+ const externalCenter = this.getExternalCoordinate(projectedCenter);
287
302
  this.viewChange.emit({
288
- center: lonLatCenter,
303
+ center: externalCenter,
289
304
  zoom: view.getZoom() ?? 0,
290
305
  rotation: view.getRotation() ?? 0,
291
306
  });
292
307
  }
293
308
  }
294
309
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlMapComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
295
- 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>
296
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 });
297
312
  }
298
313
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlMapComponent, decorators: [{
299
314
  type: Component,
300
315
  args: [{ selector: 'ol-map', template: `<div class="ol-map-container" #mapContainer></div>
301
316
  <ng-content />`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;width:100%;height:100%;position:relative}.ol-map-container{width:100%;height:100%}\n"] }]
302
- }], 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 }] }] } });
303
318
 
304
319
  // OlGeometryService — general purpose geometry helpers
305
320
  /**
@@ -511,44 +526,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
511
526
  }] });
512
527
 
513
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
+ }
514
539
  /**
515
540
  * Converts an OpenLayers feature to the internal Feature format.
516
- * Handles coordinate extraction and geometry type mapping.
541
+ * Handles coordinate extraction and geometry type mapping with custom projections.
517
542
  *
518
543
  * @param olFeature - The OpenLayers feature to convert
544
+ * @param options - Projection source and target codes
519
545
  * @returns The converted Feature with normalized structure
520
546
  */
521
- function olFeatureToFeature(olFeature) {
547
+ function olFeatureToFeature(olFeature, options) {
522
548
  // Unwrap spider features
523
549
  const spiderFeature = olFeature.get('spider-feature');
524
550
  if (spiderFeature) {
525
- return olFeatureToFeature(spiderFeature);
551
+ return olFeatureToFeature(spiderFeature, options);
526
552
  }
527
553
  // Unwrap single-item clusters
528
554
  const clusterFeatures = olFeature.get('features');
529
555
  if (Array.isArray(clusterFeatures) && clusterFeatures.length === 1) {
530
- return olFeatureToFeature(clusterFeatures[0]);
556
+ return olFeatureToFeature(clusterFeatures[0], options);
531
557
  }
532
558
  const geometry = olFeature.getGeometry();
533
559
  const geomType = geometry?.getType() ?? 'Point';
560
+ const sourceProj = options?.targetProjection;
561
+ const targetProj = options?.sourceProjection;
534
562
  // Convert coordinates based on geometry type
535
563
  let coordinates;
536
564
  if (geomType === 'Circle') {
537
565
  // ol/geom/Circle has no getCoordinates() — use getCenter() instead
538
566
  const circle = geometry;
539
- coordinates = circle.getCenter();
567
+ coordinates = transformCoords(circle.getCenter(), sourceProj, targetProj);
540
568
  }
541
569
  else {
542
570
  // oxlint-disable-next-line no-explicit-any
543
571
  const olCoords = geometry.getCoordinates();
544
- if (Array.isArray(olCoords) && Array.isArray(olCoords[0])) {
545
- // Multi-coordinate structures (LineString, Polygon, etc.)
546
- coordinates = olCoords;
547
- }
548
- else {
549
- // Single point
550
- coordinates = olCoords;
551
- }
572
+ coordinates = transformCoords(olCoords, sourceProj, targetProj);
552
573
  }
553
574
  return {
554
575
  id: olFeature.getId()?.toString() ?? `feature-${Math.random().toString(36).slice(2)}`,
@@ -562,7 +583,9 @@ function olFeatureToFeature(olFeature) {
562
583
  /**
563
584
  * Converts an internal Feature to an OpenLayers feature.
564
585
  */
565
- function featureToOlFeature(feature) {
586
+ function featureToOlFeature(feature, options) {
587
+ const sourceProj = options?.sourceProjection ?? 'EPSG:4326';
588
+ const targetProj = options?.targetProjection ?? 'EPSG:3857';
566
589
  const geom = feature.geometry;
567
590
  let geometry;
568
591
  if (!geom.coordinates) {
@@ -570,14 +593,14 @@ function featureToOlFeature(feature) {
570
593
  }
571
594
  else if (geom.type === 'Point') {
572
595
  const coords = geom.coordinates;
573
- geometry = new Point(fromLonLat(coords));
596
+ geometry = new Point(transformCoords(coords, sourceProj, targetProj));
574
597
  }
575
598
  else if (geom.type === 'LineString') {
576
- const coords = geom.coordinates.map((c) => fromLonLat(c));
599
+ const coords = transformCoords(geom.coordinates, sourceProj, targetProj);
577
600
  geometry = new LineString(coords);
578
601
  }
579
602
  else if (geom.type === 'Polygon') {
580
- const rings = geom.coordinates.map((ring) => ring.map((c) => fromLonLat(c)));
603
+ const rings = transformCoords(geom.coordinates, sourceProj, targetProj);
581
604
  geometry = new Polygon(rings);
582
605
  }
583
606
  else {
@@ -166,12 +166,13 @@ function buildVectorLayer(config, source) {
166
166
  layer.set('id', config.id);
167
167
  layer.set('cluster-config', clusterCfg);
168
168
  layer.set('style-fn', styleFn);
169
+ layer.set('coordinate-projection', config.coordinateProjection);
169
170
  return layer;
170
171
  }
171
172
  function buildHeatmapLayer(config) {
172
173
  const vectorSource = new VectorSource();
173
174
  if (config.features && config.features.length > 0) {
174
- const olFeatures = config.features.map(featureToOlFeature);
175
+ const olFeatures = config.features.map((f) => featureToOlFeature(f));
175
176
  vectorSource.addFeatures(olFeatures);
176
177
  }
177
178
  const layer = new HeatmapLayer({
@@ -392,9 +393,16 @@ class OlLayerService {
392
393
  sourceOptions.format = new KML();
393
394
  }
394
395
  const vectorSource = new VectorSource(sourceOptions);
396
+ const targetProj = (typeof map.getView === 'function'
397
+ ? map.getView()?.getProjection()?.getCode()
398
+ : undefined) ?? 'EPSG:3857';
399
+ const sourceProj = vConfig.coordinateProjection ?? 'EPSG:4326';
395
400
  if (vConfig.features && vConfig.features.length > 0) {
396
401
  const olFeatures = vConfig.features.map((f) => {
397
- const olf = featureToOlFeature(f);
402
+ const olf = featureToOlFeature(f, {
403
+ sourceProjection: sourceProj,
404
+ targetProjection: targetProj,
405
+ });
398
406
  if (f.style)
399
407
  olf.set(STYLE_PROP, f.style);
400
408
  return olf;
@@ -462,6 +470,28 @@ class OlLayerService {
462
470
  const layer = this.layerCache.get(id);
463
471
  if (map && layer) {
464
472
  map.removeLayer(layer);
473
+ // Explicitly dispose sources to prevent memory leaks
474
+ if ('getSource' in layer) {
475
+ const source = layer.getSource();
476
+ if (source) {
477
+ // If it's a ClusterSource, dispose the underlying source first
478
+ if ('getSource' in source && typeof source.getSource === 'function') {
479
+ const underlyingSource = source.getSource();
480
+ if (underlyingSource && typeof underlyingSource.dispose === 'function') {
481
+ if (typeof underlyingSource.clear === 'function') {
482
+ underlyingSource.clear(true);
483
+ }
484
+ underlyingSource.dispose();
485
+ }
486
+ }
487
+ if (typeof source.dispose === 'function') {
488
+ if (typeof source.clear === 'function') {
489
+ source.clear(true);
490
+ }
491
+ source.dispose();
492
+ }
493
+ }
494
+ }
465
495
  layer.dispose();
466
496
  this.layerCache.delete(id);
467
497
  this.updateLayerState();
@@ -580,6 +610,11 @@ class OlLayerService {
580
610
  const layer = this.layerCache.get(id);
581
611
  if (!(layer instanceof VectorLayer))
582
612
  return;
613
+ const sourceProj = layer.get('coordinate-projection') ?? 'EPSG:4326';
614
+ const map = this.mapService.getMap();
615
+ const targetProj = (map && typeof map.getView === 'function'
616
+ ? map.getView()?.getProjection()?.getCode()
617
+ : undefined) ?? 'EPSG:3857';
583
618
  const source = layer.getSource();
584
619
  if (!source)
585
620
  return;
@@ -605,7 +640,10 @@ class OlLayerService {
605
640
  const featuresToAdd = features.filter((f) => !existingIds.has(f.id));
606
641
  if (featuresToAdd.length > 0) {
607
642
  const olFeatures = featuresToAdd.map((f) => {
608
- const olf = featureToOlFeature(f);
643
+ const olf = featureToOlFeature(f, {
644
+ sourceProjection: sourceProj,
645
+ targetProjection: targetProj,
646
+ });
609
647
  if (f.style)
610
648
  olf.set(STYLE_PROP, f.style);
611
649
  return olf;
@@ -674,6 +712,7 @@ class OlVectorLayerComponent {
674
712
  style = input(...(ngDevMode ? [undefined, { debugName: "style" }] : /* istanbul ignore next */ []));
675
713
  cluster = input(...(ngDevMode ? [undefined, { debugName: "cluster" }] : /* istanbul ignore next */ []));
676
714
  clusterComponent = contentChild(OlClusterComponent, ...(ngDevMode ? [{ debugName: "clusterComponent" }] : /* istanbul ignore next */ []));
715
+ coordinateProjection = input('EPSG:4326', ...(ngDevMode ? [{ debugName: "coordinateProjection" }] : /* istanbul ignore next */ []));
677
716
  constructor() {
678
717
  // Initialize layer after DOM is ready
679
718
  afterNextRender(() => {
@@ -701,6 +740,7 @@ class OlVectorLayerComponent {
701
740
  visible: this.visible(),
702
741
  style: this.style(),
703
742
  cluster: resolvedClusterConfig,
743
+ coordinateProjection: this.coordinateProjection(),
704
744
  });
705
745
  });
706
746
  // Effect to sync features when input changes
@@ -737,7 +777,7 @@ class OlVectorLayerComponent {
737
777
  });
738
778
  }
739
779
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlVectorLayerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
740
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.13", 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 }, url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", 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 } }, queries: [{ propertyName: "clusterComponent", first: true, predicate: OlClusterComponent, descendants: true, isSignal: true }], ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
780
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.13", 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 }, url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", 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 }, coordinateProjection: { classPropertyName: "coordinateProjection", publicName: "coordinateProjection", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "clusterComponent", first: true, predicate: OlClusterComponent, descendants: true, isSignal: true }], ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
741
781
  }
742
782
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlVectorLayerComponent, decorators: [{
743
783
  type: Component,
@@ -746,7 +786,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
746
786
  template: '',
747
787
  changeDetection: ChangeDetectionStrategy.OnPush,
748
788
  }]
749
- }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: true }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", 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 }] }], clusterComponent: [{ type: i0.ContentChild, args: [i0.forwardRef(() => OlClusterComponent), { isSignal: true }] }] } });
789
+ }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: true }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", 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 }] }], clusterComponent: [{ type: i0.ContentChild, args: [i0.forwardRef(() => OlClusterComponent), { isSignal: true }] }], coordinateProjection: [{ type: i0.Input, args: [{ isSignal: true, alias: "coordinateProjection", required: false }] }] } });
750
790
 
751
791
  // OlTileLayerComponent
752
792
  class OlTileLayerComponent {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/openlayers",
3
- "version": "0.5.1",
3
+ "version": "0.7.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": {
@@ -231,5 +231,55 @@ declare class OlControlService {
231
231
  declare function withControls(): OlFeature<'controls'>;
232
232
  declare function provideControls(): OlFeature<'controls'>;
233
233
 
234
- export { OlAttributionControlComponent, OlBasemapSwitcherComponent, OlControlService, OlFullscreenControlComponent, OlGeolocationControlComponent, OlLayerSwitcherComponent, OlRotateControlComponent, OlScaleLineControlComponent, OlZoomControlComponent, ROTATE_CONTROL_MAP_SERVICE, provideControls, withControls };
235
- export type { BasemapConfig, BasemapSwitcherPosition, ControlConfig, ControlPosition, LayerSwitcherItem, LayerSwitcherPosition, RotateControlMapService };
234
+ type TimelinePosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
235
+
236
+ /**
237
+ * A premium, reactive visual timeline component for OpenLayers.
238
+ * Orchestrates time-series animations by binding directly to OlTimeService.
239
+ *
240
+ * Uses native controls, CSS grid/flex layouts, and a sleek glassmorphic theme.
241
+ * Completely free of CommonModule or FormsModule dependencies for maximum performance.
242
+ *
243
+ * @usageNotes
244
+ * ```html
245
+ * <ol-timeline
246
+ * [startTime]="1700000000000"
247
+ * [endTime]="1700086400000"
248
+ * [playSpeed]="60"
249
+ * [loop]="true"
250
+ * position="bottom-center">
251
+ * </ol-timeline>
252
+ * ```
253
+ */
254
+ declare class OlTimelineComponent {
255
+ private timeService;
256
+ /** Start bounds of time-series (Epoch ms) */
257
+ startTime: _angular_core.InputSignal<number>;
258
+ /** End bounds of time-series (Epoch ms) */
259
+ endTime: _angular_core.InputSignal<number>;
260
+ /** Default speed multiplier (e.g. 1, 5, 10, 60, 3600) */
261
+ playSpeed: _angular_core.InputSignal<number>;
262
+ /** Loop playback when reaching endTime */
263
+ loop: _angular_core.InputSignal<boolean>;
264
+ /** Position overlay alignment */
265
+ position: _angular_core.InputSignal<TimelinePosition>;
266
+ /** Custom label formatter */
267
+ formatLabel: _angular_core.InputSignal<(time: number) => string>;
268
+ /** Outputs */
269
+ timeChange: _angular_core.OutputEmitterRef<number>;
270
+ playStateChange: _angular_core.OutputEmitterRef<boolean>;
271
+ /** Computeds binding directly to OlTimeService */
272
+ currentTime: _angular_core.Signal<number>;
273
+ isPlaying: _angular_core.Signal<boolean>;
274
+ speed: _angular_core.Signal<number>;
275
+ formattedTime: _angular_core.Signal<string>;
276
+ constructor();
277
+ togglePlay(): void;
278
+ onScrub(event: Event): void;
279
+ onSpeedChange(event: Event): void;
280
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlTimelineComponent, never>;
281
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlTimelineComponent, "ol-timeline", never, { "startTime": { "alias": "startTime"; "required": true; "isSignal": true; }; "endTime": { "alias": "endTime"; "required": true; "isSignal": true; }; "playSpeed": { "alias": "playSpeed"; "required": false; "isSignal": true; }; "loop": { "alias": "loop"; "required": false; "isSignal": true; }; "position": { "alias": "position"; "required": false; "isSignal": true; }; "formatLabel": { "alias": "formatLabel"; "required": false; "isSignal": true; }; }, { "timeChange": "timeChange"; "playStateChange": "playStateChange"; }, never, never, true, never>;
282
+ }
283
+
284
+ export { OlAttributionControlComponent, OlBasemapSwitcherComponent, OlControlService, OlFullscreenControlComponent, OlGeolocationControlComponent, OlLayerSwitcherComponent, OlRotateControlComponent, OlScaleLineControlComponent, OlTimelineComponent, OlZoomControlComponent, ROTATE_CONTROL_MAP_SERVICE, provideControls, withControls };
285
+ export type { BasemapConfig, BasemapSwitcherPosition, ControlConfig, ControlPosition, LayerSwitcherItem, LayerSwitcherPosition, RotateControlMapService, TimelinePosition };
@@ -94,6 +94,7 @@ declare class OlMapComponent {
94
94
  zoom: _angular_core.InputSignal<number>;
95
95
  rotation: _angular_core.InputSignal<number>;
96
96
  projection: _angular_core.InputSignal<string>;
97
+ coordinateProjection: _angular_core.InputSignal<string>;
97
98
  viewChange: _angular_core.OutputEmitterRef<ViewState>;
98
99
  mapClick: _angular_core.OutputEmitterRef<MapClickEvent>;
99
100
  mapDblClick: _angular_core.OutputEmitterRef<MapClickEvent>;
@@ -101,6 +102,8 @@ declare class OlMapComponent {
101
102
  private map?;
102
103
  private resizeObserver?;
103
104
  constructor();
105
+ private getProjectedCoordinate;
106
+ private getExternalCoordinate;
104
107
  private initMap;
105
108
  private destroyMap;
106
109
  private updateCenter;
@@ -108,7 +111,7 @@ declare class OlMapComponent {
108
111
  private updateRotation;
109
112
  private emitViewChange;
110
113
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlMapComponent, never>;
111
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlMapComponent, "ol-map", never, { "center": { "alias": "center"; "required": false; "isSignal": true; }; "zoom": { "alias": "zoom"; "required": false; "isSignal": true; }; "rotation": { "alias": "rotation"; "required": false; "isSignal": true; }; "projection": { "alias": "projection"; "required": false; "isSignal": true; }; }, { "viewChange": "viewChange"; "mapClick": "mapClick"; "mapDblClick": "mapDblClick"; }, never, ["*"], true, never>;
114
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlMapComponent, "ol-map", never, { "center": { "alias": "center"; "required": false; "isSignal": true; }; "zoom": { "alias": "zoom"; "required": false; "isSignal": true; }; "rotation": { "alias": "rotation"; "required": false; "isSignal": true; }; "projection": { "alias": "projection"; "required": false; "isSignal": true; }; "coordinateProjection": { "alias": "coordinateProjection"; "required": false; "isSignal": true; }; }, { "viewChange": "viewChange"; "mapClick": "mapClick"; "mapDblClick": "mapDblClick"; }, never, ["*"], true, never>;
112
115
  }
113
116
 
114
117
  /**
@@ -379,18 +382,23 @@ interface VectorResourceOptions {
379
382
  */
380
383
  declare function createVectorResource(url: Signal<string | undefined>, options?: VectorResourceOptions): Resource<Feature[]>;
381
384
 
385
+ interface ProjectionOptions {
386
+ sourceProjection?: string;
387
+ targetProjection?: string;
388
+ }
382
389
  /**
383
390
  * Converts an OpenLayers feature to the internal Feature format.
384
- * Handles coordinate extraction and geometry type mapping.
391
+ * Handles coordinate extraction and geometry type mapping with custom projections.
385
392
  *
386
393
  * @param olFeature - The OpenLayers feature to convert
394
+ * @param options - Projection source and target codes
387
395
  * @returns The converted Feature with normalized structure
388
396
  */
389
- declare function olFeatureToFeature(olFeature: Feature$1): Feature;
397
+ declare function olFeatureToFeature(olFeature: Feature$1, options?: ProjectionOptions): Feature;
390
398
  /**
391
399
  * Converts an internal Feature to an OpenLayers feature.
392
400
  */
393
- declare function featureToOlFeature(feature: Feature): Feature$1;
401
+ declare function featureToOlFeature(feature: Feature, options?: ProjectionOptions): Feature$1;
394
402
 
395
403
  type OlFeatureKind = 'layers' | 'controls' | 'interactions' | 'overlays' | 'military' | 'projections';
396
404
  interface OlFeature<Kind extends OlFeatureKind> {
@@ -33,6 +33,7 @@ interface VectorLayerConfig extends LayerConfig {
33
33
  format?: 'geojson' | 'topojson' | 'kml';
34
34
  style?: Style | ((feature: Feature) => Style);
35
35
  cluster?: ClusterConfig;
36
+ coordinateProjection?: string;
36
37
  }
37
38
  interface HeatmapLayerConfig extends LayerConfig {
38
39
  type: 'heatmap';
@@ -87,9 +88,10 @@ declare class OlVectorLayerComponent {
87
88
  style: _angular_core.InputSignal<any>;
88
89
  cluster: _angular_core.InputSignal<ClusterConfig>;
89
90
  clusterComponent: _angular_core.Signal<OlClusterComponent>;
91
+ coordinateProjection: _angular_core.InputSignal<string>;
90
92
  constructor();
91
93
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlVectorLayerComponent, never>;
92
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlVectorLayerComponent, "ol-vector-layer", never, { "id": { "alias": "id"; "required": true; "isSignal": true; }; "features": { "alias": "features"; "required": false; "isSignal": true; }; "url": { "alias": "url"; "required": false; "isSignal": true; }; "format": { "alias": "format"; "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; }; }, {}, ["clusterComponent"], never, true, never>;
94
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlVectorLayerComponent, "ol-vector-layer", never, { "id": { "alias": "id"; "required": true; "isSignal": true; }; "features": { "alias": "features"; "required": false; "isSignal": true; }; "url": { "alias": "url"; "required": false; "isSignal": true; }; "format": { "alias": "format"; "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; }; "coordinateProjection": { "alias": "coordinateProjection"; "required": false; "isSignal": true; }; }, {}, ["clusterComponent"], never, true, never>;
93
95
  }
94
96
 
95
97
  declare class OlTileLayerComponent {