@angular-helpers/openlayers 0.5.0 → 0.5.1

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.
@@ -1,44 +1,357 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, signal, computed, Injectable, input, ChangeDetectionStrategy, Component, DestroyRef, contentChild, afterNextRender, effect } from '@angular/core';
2
+ import { inject, signal, computed, Injectable, input, output, ChangeDetectionStrategy, Component, DestroyRef, contentChild, afterNextRender, effect } from '@angular/core';
3
3
  import VectorLayer from 'ol/layer/Vector';
4
4
  import HeatmapLayer from 'ol/layer/Heatmap';
5
5
  import TileLayer from 'ol/layer/Tile';
6
6
  import ImageLayer from 'ol/layer/Image';
7
7
  import VectorSource from 'ol/source/Vector';
8
- import OLFeature from 'ol/Feature';
9
- import { Point, LineString, Polygon, Circle } from 'ol/geom';
10
- import { fromLonLat } from 'ol/proj';
11
- import { getCenter } from 'ol/extent';
12
- import { Style, Circle as Circle$1, Stroke, Fill, Icon } from 'ol/style';
13
- import Text from 'ol/style/Text';
14
8
  import ClusterSource from 'ol/source/Cluster';
9
+ import GeoJSON from 'ol/format/GeoJSON';
10
+ import TopoJSON from 'ol/format/TopoJSON';
11
+ import KML from 'ol/format/KML';
12
+ import { getCenter } from 'ol/extent';
13
+ import { Point, LineString, Polygon, Circle as Circle$1 } from 'ol/geom';
14
+ import { featureToOlFeature, olFeatureToFeature, OlMapService } from '@angular-helpers/openlayers/core';
15
15
  import OSM from 'ol/source/OSM';
16
16
  import XYZ from 'ol/source/XYZ';
17
17
  import TileWMS from 'ol/source/TileWMS';
18
18
  import ImageWMS from 'ol/source/ImageWMS';
19
19
  import ImageStatic from 'ol/source/ImageStatic';
20
- import GeoJSON from 'ol/format/GeoJSON';
21
- import TopoJSON from 'ol/format/TopoJSON';
22
- import KML from 'ol/format/KML';
23
- import { OlMapService } from '@angular-helpers/openlayers/core';
20
+ import { Style, Text, Fill, Circle, Stroke, Icon } from 'ol/style';
21
+ import OLFeature from 'ol/Feature';
22
+ import { fromLonLat } from 'ol/proj';
24
23
  import WebGLVectorLayer from 'ol/layer/WebGLVector';
25
24
  import WebGLTileLayer from 'ol/layer/WebGLTile';
26
25
  import WebGLVectorTileLayer from 'ol/layer/WebGLVectorTile';
27
26
  import VectorTileSource from 'ol/source/VectorTile';
28
27
  import MVT from 'ol/format/MVT';
29
28
 
30
- // OlLayerService
31
- /**
32
- * Internal property key used to stash the abstract style metadata on the
33
- * underlying `ol/Feature` so the layer style function can resolve a
34
- * per-feature visual without colliding with user `properties`.
35
- */
29
+ function buildTileSource(config) {
30
+ switch (config.type) {
31
+ case 'osm':
32
+ return new OSM({ attributions: config.attributions });
33
+ case 'xyz':
34
+ return new XYZ({ url: config.url, attributions: config.attributions });
35
+ case 'wms':
36
+ return new TileWMS({
37
+ url: config.url,
38
+ params: config.params ?? {},
39
+ attributions: config.attributions,
40
+ });
41
+ default:
42
+ return new OSM();
43
+ }
44
+ }
45
+ function buildImageSource(config) {
46
+ if (config.type === 'static') {
47
+ return new ImageStatic({
48
+ url: config.url,
49
+ imageExtent: config.imageExtent ?? [0, 0, 1, 1],
50
+ });
51
+ }
52
+ return new ImageWMS({ url: config.url, params: config.params });
53
+ }
54
+
55
+ function createClusterStyleFn(clusterCfg, styleFn, defaultStyle) {
56
+ return (olFeature, resolution) => {
57
+ const features = olFeature.get('features');
58
+ const size = features ? features.length : 0;
59
+ // Render cluster badge
60
+ if (size > 1) {
61
+ const showCount = clusterCfg?.showCount ?? true;
62
+ return new Style({
63
+ image: new Circle({
64
+ radius: 15 + Math.min(size * 2, 15),
65
+ fill: new Fill({ color: 'rgba(255, 100, 100, 0.8)' }),
66
+ stroke: new Stroke({ color: '#fff', width: 2 }),
67
+ }),
68
+ text: showCount
69
+ ? new Text({
70
+ text: String(size),
71
+ fill: new Fill({ color: '#fff' }),
72
+ })
73
+ : undefined,
74
+ });
75
+ }
76
+ // Single feature in cluster: unwrap and call styleFn
77
+ const originalFeature = features?.[0];
78
+ if (originalFeature) {
79
+ // 1. Feature level override
80
+ if (clusterCfg?.featureStyle) {
81
+ // OpenLayers styles are mutable. Clone before modifying geometry to prevent global breakage.
82
+ const style = clusterCfg.featureStyle; // Cast since it might be abstract
83
+ if (style instanceof Style) {
84
+ const origGeom = originalFeature.getGeometry();
85
+ if (origGeom) {
86
+ const clonedStyle = style.clone();
87
+ clonedStyle.setGeometry(origGeom);
88
+ return clonedStyle;
89
+ }
90
+ }
91
+ return style;
92
+ }
93
+ // 2. Original Layer style
94
+ if (styleFn) {
95
+ const style = styleFn(originalFeature, resolution);
96
+ if (style instanceof Style) {
97
+ const origGeom = originalFeature.getGeometry();
98
+ if (origGeom) {
99
+ const clonedStyle = style.clone();
100
+ clonedStyle.setGeometry(origGeom);
101
+ return clonedStyle;
102
+ }
103
+ }
104
+ return style;
105
+ }
106
+ }
107
+ // 3. Fallback to styleFn or default
108
+ return styleFn ? styleFn(olFeature, resolution) : defaultStyle;
109
+ };
110
+ }
111
+
112
+ const STYLE_PROP$1 = '__angular_helpers_style__';
113
+ const defaultStyle = new Style({
114
+ image: new Circle({
115
+ radius: 6,
116
+ fill: new Fill({ color: '#3399CC' }),
117
+ stroke: new Stroke({ color: '#fff', width: 2 }),
118
+ }),
119
+ });
120
+ function buildVectorLayer(config, source) {
121
+ const clusterCfg = config.cluster;
122
+ const userStyle = config.style;
123
+ const styleFn = (olFeature, resolution) => {
124
+ const abstractStyle = olFeature.get(STYLE_PROP$1);
125
+ if (abstractStyle) {
126
+ const style = new Style();
127
+ const { icon, fill, stroke } = abstractStyle;
128
+ if (icon?.src) {
129
+ style.setImage(new Icon({
130
+ src: icon.src,
131
+ ...(icon.size ? { size: icon.size } : {}),
132
+ ...(icon.anchor ? { anchor: icon.anchor } : {}),
133
+ }));
134
+ }
135
+ if (fill)
136
+ style.setFill(new Fill({ color: fill.color }));
137
+ if (stroke)
138
+ style.setStroke(new Stroke({ color: stroke.color, width: stroke.width }));
139
+ if (icon?.src || fill || stroke)
140
+ return style;
141
+ }
142
+ if (userStyle) {
143
+ if (typeof userStyle === 'function') {
144
+ const feature = {
145
+ id: String(olFeature.getId() ?? ''),
146
+ geometry: {
147
+ type: olFeature.getGeometry()?.getType(),
148
+ coordinates: [],
149
+ },
150
+ properties: olFeature.getProperties(),
151
+ };
152
+ return userStyle(feature, resolution);
153
+ }
154
+ return userStyle;
155
+ }
156
+ return defaultStyle;
157
+ };
158
+ const clusterStyleFn = createClusterStyleFn(clusterCfg, styleFn, defaultStyle);
159
+ const layer = new VectorLayer({
160
+ source,
161
+ visible: config.visible ?? true,
162
+ opacity: config.opacity ?? 1,
163
+ zIndex: config.zIndex,
164
+ style: clusterCfg?.enabled ? clusterStyleFn : styleFn,
165
+ });
166
+ layer.set('id', config.id);
167
+ layer.set('cluster-config', clusterCfg);
168
+ layer.set('style-fn', styleFn);
169
+ return layer;
170
+ }
171
+ function buildHeatmapLayer(config) {
172
+ const vectorSource = new VectorSource();
173
+ if (config.features && config.features.length > 0) {
174
+ const olFeatures = config.features.map(featureToOlFeature);
175
+ vectorSource.addFeatures(olFeatures);
176
+ }
177
+ const layer = new HeatmapLayer({
178
+ source: vectorSource,
179
+ visible: config.visible ?? true,
180
+ opacity: config.opacity ?? 1,
181
+ zIndex: config.zIndex,
182
+ ...(config.blur !== undefined && { blur: config.blur }),
183
+ ...(config.radius !== undefined && { radius: config.radius }),
184
+ ...(config.weight !== undefined && {
185
+ weight: config.weight,
186
+ }),
187
+ });
188
+ layer.set('id', config.id);
189
+ return layer;
190
+ }
191
+ function buildTileLayer(config, source) {
192
+ const layer = new TileLayer({
193
+ source,
194
+ visible: config.visible ?? true,
195
+ opacity: config.opacity ?? 1,
196
+ zIndex: config.zIndex,
197
+ });
198
+ layer.set('id', config.id);
199
+ return layer;
200
+ }
201
+ function buildImageLayer(config, source) {
202
+ const layer = new ImageLayer({
203
+ source,
204
+ visible: config.visible ?? true,
205
+ opacity: config.opacity ?? 1,
206
+ zIndex: config.zIndex,
207
+ });
208
+ layer.set('id', config.id);
209
+ return layer;
210
+ }
211
+
212
+ class SpiderficationManager {
213
+ spiderSource = new VectorSource();
214
+ spiderLayer = new VectorLayer({
215
+ source: this.spiderSource,
216
+ zIndex: 9999,
217
+ properties: { 'is-spider-layer': true },
218
+ });
219
+ mapClickListenerRegistered = false;
220
+ map = null;
221
+ layerCache;
222
+ constructor(layerCache) {
223
+ this.layerCache = layerCache;
224
+ }
225
+ register(map) {
226
+ if (this.mapClickListenerRegistered)
227
+ return;
228
+ this.mapClickListenerRegistered = true;
229
+ this.map = map;
230
+ map.addLayer(this.spiderLayer);
231
+ // Unspiderfy on map movement or zoom
232
+ map.on('movestart', () => this.unspiderfy());
233
+ map.on('singleclick', (e) => {
234
+ let handled = false;
235
+ let keepSpiderOpen = false;
236
+ map.forEachFeatureAtPixel(e.pixel, (f, l) => {
237
+ if (handled)
238
+ return;
239
+ // Check if we clicked a spider item
240
+ if (l === this.spiderLayer) {
241
+ const originalOlFeature = f.get('spider-feature');
242
+ if (originalOlFeature) {
243
+ const layerId = f.get('cluster-layer-id');
244
+ const layerObj = this.layerCache.get(layerId);
245
+ if (layerObj) {
246
+ const clusterCfg = layerObj.get('cluster-config');
247
+ if (clusterCfg?.onSpiderfyClick) {
248
+ // Use olFeatureToFeature so coordinates are properly extracted!
249
+ const feat = olFeatureToFeature(originalOlFeature);
250
+ clusterCfg.onSpiderfyClick(feat);
251
+ }
252
+ }
253
+ }
254
+ handled = true;
255
+ keepSpiderOpen = true; // Keep spider open when clicking a leg
256
+ return;
257
+ }
258
+ // Check if we clicked a cluster
259
+ if (!l)
260
+ return;
261
+ const features = f.get('features');
262
+ if (features && features.length > 1) {
263
+ const clusterCfg = l.get('cluster-config');
264
+ if (clusterCfg?.spiderfyOnSelect) {
265
+ keepSpiderOpen = true;
266
+ handled = true;
267
+ // Execute layer manipulations outside the synchronous event loop
268
+ setTimeout(() => {
269
+ this.spiderfy(map, f, features, l, clusterCfg);
270
+ });
271
+ }
272
+ }
273
+ });
274
+ // Cleanup existing spider layer if we clicked anything else
275
+ if (!keepSpiderOpen) {
276
+ this.unspiderfy();
277
+ }
278
+ });
279
+ }
280
+ unspiderfy() {
281
+ this.spiderSource.clear();
282
+ }
283
+ spiderfy(map, clusterFeature, features, parentLayer, cfg) {
284
+ this.unspiderfy();
285
+ const count = features.length;
286
+ const centerGeom = clusterFeature.getGeometry();
287
+ if (!centerGeom || centerGeom.getType() !== 'Point')
288
+ return;
289
+ const centerCoords = centerGeom.getCoordinates();
290
+ const resolution = map.getView().getResolution() ?? 1;
291
+ const baseRadius = 30; // 30 pixels
292
+ const radius = baseRadius + count * 2;
293
+ const angleStep = (2 * Math.PI) / count;
294
+ const spiderFeatures = [];
295
+ const styleFn = parentLayer.get('style-fn');
296
+ features.forEach((f, i) => {
297
+ let x, y;
298
+ if (count <= 8) {
299
+ // Circle layout for small number of features
300
+ const angle = i * angleStep;
301
+ x = centerCoords[0] + radius * Math.cos(angle) * resolution;
302
+ y = centerCoords[1] + radius * Math.sin(angle) * resolution;
303
+ }
304
+ else {
305
+ // Spiral layout (caracol) for many features
306
+ const initialRadius = 20;
307
+ const legLength = 15;
308
+ const spiralAngleStep = 0.5;
309
+ const angle = i * spiralAngleStep;
310
+ const r = initialRadius + legLength * (angle / Math.PI);
311
+ x = centerCoords[0] + r * Math.cos(angle) * resolution;
312
+ y = centerCoords[1] + r * Math.sin(angle) * resolution;
313
+ }
314
+ const legGeom = new Point([x, y]);
315
+ // Create leg line
316
+ const lineFeature = new OLFeature(new LineString([centerCoords, [x, y]]));
317
+ lineFeature.setStyle(new Style({ stroke: new Stroke({ color: 'rgba(0,0,0,0.5)', width: 2 }) }));
318
+ spiderFeatures.push(lineFeature);
319
+ // Create point feature
320
+ const pointFeature = new OLFeature(legGeom);
321
+ // Determine style for the spider leg point
322
+ let pointStyle = undefined;
323
+ if (cfg?.featureStyle) {
324
+ pointStyle = cfg.featureStyle;
325
+ }
326
+ else if (styleFn) {
327
+ pointStyle = styleFn(f, resolution);
328
+ }
329
+ // Ensure we always have a visible style even if the original feature has none
330
+ if (!pointStyle || (Array.isArray(pointStyle) && pointStyle.length === 0)) {
331
+ pointStyle = new Style({
332
+ image: new Circle({
333
+ radius: 6,
334
+ fill: new Fill({ color: '#3399CC' }),
335
+ stroke: new Stroke({ color: '#fff', width: 2 }),
336
+ }),
337
+ });
338
+ }
339
+ pointFeature.setStyle(pointStyle);
340
+ pointFeature.set('spider-feature', f);
341
+ pointFeature.set('cluster-layer-id', parentLayer.get('id'));
342
+ spiderFeatures.push(pointFeature);
343
+ });
344
+ this.spiderSource.addFeatures(spiderFeatures);
345
+ }
346
+ }
347
+
36
348
  const STYLE_PROP = '__angular_helpers_style__';
37
349
  class OlLayerService {
38
350
  mapService = inject(OlMapService);
39
351
  layerCache = new Map();
40
352
  pendingConfigs = [];
41
353
  layerState = signal([], ...(ngDevMode ? [{ debugName: "layerState" }] : /* istanbul ignore next */ []));
354
+ spiderManager = new SpiderficationManager(this.layerCache);
42
355
  layers = computed(() => this.layerState(), ...(ngDevMode ? [{ debugName: "layers" }] : /* istanbul ignore next */ []));
43
356
  visibleLayers = computed(() => this.layerState().filter((l) => l.visible), ...(ngDevMode ? [{ debugName: "visibleLayers" }] : /* istanbul ignore next */ []));
44
357
  tileLayers = computed(() => this.layerState().filter((l) => l.type === 'tile'), ...(ngDevMode ? [{ debugName: "tileLayers" }] : /* istanbul ignore next */ []));
@@ -64,18 +377,74 @@ class OlLayerService {
64
377
  }
65
378
  }
66
379
  createLayer(config, map) {
380
+ let layer;
67
381
  switch (config.type) {
68
- case 'vector':
69
- return this.createVectorLayer(config, map);
382
+ case 'vector': {
383
+ const vConfig = config;
384
+ const sourceOptions = {};
385
+ if (vConfig.url && vConfig.format) {
386
+ sourceOptions.url = vConfig.url;
387
+ if (vConfig.format === 'geojson')
388
+ sourceOptions.format = new GeoJSON();
389
+ else if (vConfig.format === 'topojson')
390
+ sourceOptions.format = new TopoJSON();
391
+ else if (vConfig.format === 'kml')
392
+ sourceOptions.format = new KML();
393
+ }
394
+ const vectorSource = new VectorSource(sourceOptions);
395
+ if (vConfig.features && vConfig.features.length > 0) {
396
+ const olFeatures = vConfig.features.map((f) => {
397
+ const olf = featureToOlFeature(f);
398
+ if (f.style)
399
+ olf.set(STYLE_PROP, f.style);
400
+ return olf;
401
+ });
402
+ vectorSource.addFeatures(olFeatures);
403
+ }
404
+ const clusterCfg = vConfig.cluster;
405
+ const source = clusterCfg?.enabled
406
+ ? new ClusterSource({
407
+ source: vectorSource,
408
+ distance: clusterCfg.distance ?? 40,
409
+ minDistance: clusterCfg.minDistance ?? 20,
410
+ geometryFunction: (feature) => {
411
+ const geometry = feature.getGeometry();
412
+ if (!geometry)
413
+ return null;
414
+ if (geometry.getType() === 'Point')
415
+ return geometry;
416
+ return new Point(getCenter(geometry.getExtent()));
417
+ },
418
+ })
419
+ : vectorSource;
420
+ layer = buildVectorLayer(vConfig, source);
421
+ if (clusterCfg?.spiderfyOnSelect) {
422
+ this.spiderManager.register(map);
423
+ }
424
+ break;
425
+ }
70
426
  case 'heatmap':
71
- return this.createHeatmapLayer(config, map);
72
- case 'tile':
73
- return this.createTileLayer(config, map);
74
- case 'image':
75
- return this.createImageLayer(config, map);
427
+ layer = buildHeatmapLayer(config);
428
+ break;
429
+ case 'tile': {
430
+ const tConfig = config;
431
+ const source = buildTileSource(tConfig.source);
432
+ layer = buildTileLayer(tConfig, source);
433
+ break;
434
+ }
435
+ case 'image': {
436
+ const iConfig = config;
437
+ const source = buildImageSource(iConfig.source);
438
+ layer = buildImageLayer(iConfig, source);
439
+ break;
440
+ }
76
441
  default:
77
442
  return { id: config.id };
78
443
  }
444
+ map.addLayer(layer);
445
+ this.layerCache.set(config.id, layer);
446
+ this.updateLayerState();
447
+ return { id: config.id };
79
448
  }
80
449
  getLayer(id) {
81
450
  return this.layerCache.get(id);
@@ -84,7 +453,6 @@ class OlLayerService {
84
453
  return this.layerCache.has(id);
85
454
  }
86
455
  removeLayer(id) {
87
- // Cancel if it's still pending (map not ready yet)
88
456
  const pendingIdx = this.pendingConfigs.findIndex((c) => c.id === id);
89
457
  if (pendingIdx !== -1) {
90
458
  this.pendingConfigs.splice(pendingIdx, 1);
@@ -94,6 +462,7 @@ class OlLayerService {
94
462
  const layer = this.layerCache.get(id);
95
463
  if (map && layer) {
96
464
  map.removeLayer(layer);
465
+ layer.dispose();
97
466
  this.layerCache.delete(id);
98
467
  this.updateLayerState();
99
468
  }
@@ -106,9 +475,8 @@ class OlLayerService {
106
475
  }
107
476
  else {
108
477
  const pending = this.pendingConfigs.find((c) => c.id === id);
109
- if (pending) {
478
+ if (pending)
110
479
  pending.visible = visible;
111
- }
112
480
  }
113
481
  }
114
482
  toggleVisibility(id) {
@@ -129,9 +497,8 @@ class OlLayerService {
129
497
  }
130
498
  else {
131
499
  const pending = this.pendingConfigs.find((c) => c.id === id);
132
- if (pending) {
500
+ if (pending)
133
501
  pending.opacity = opacity;
134
- }
135
502
  }
136
503
  }
137
504
  setZIndex(id, zIndex) {
@@ -142,8 +509,25 @@ class OlLayerService {
142
509
  }
143
510
  else {
144
511
  const pending = this.pendingConfigs.find((c) => c.id === id);
145
- if (pending) {
512
+ if (pending)
146
513
  pending.zIndex = zIndex;
514
+ }
515
+ }
516
+ setClusterDistance(id, distance) {
517
+ const layer = this.layerCache.get(id);
518
+ if (layer instanceof VectorLayer) {
519
+ const source = layer.getSource();
520
+ if (source && 'setDistance' in source) {
521
+ source.setDistance(distance);
522
+ }
523
+ }
524
+ }
525
+ setClusterMinDistance(id, minDistance) {
526
+ const layer = this.layerCache.get(id);
527
+ if (layer instanceof VectorLayer) {
528
+ const source = layer.getSource();
529
+ if (source && 'setMinDistance' in source) {
530
+ source.setMinDistance(minDistance);
147
531
  }
148
532
  }
149
533
  }
@@ -179,11 +563,6 @@ class OlLayerService {
179
563
  getZIndex(id) {
180
564
  return this.layerCache.get(id)?.getZIndex() ?? 0;
181
565
  }
182
- /**
183
- * Clears all features from a vector layer's source.
184
- * Does not remove the layer itself.
185
- * @param id - Layer identifier
186
- */
187
566
  clearFeatures(id) {
188
567
  const layer = this.layerCache.get(id);
189
568
  if (!(layer instanceof VectorLayer))
@@ -191,19 +570,12 @@ class OlLayerService {
191
570
  const source = layer.getSource();
192
571
  if (!source)
193
572
  return;
194
- // Handle Cluster source: clear the underlying VectorSource
195
573
  const clusterSource = source;
196
574
  const vectorSource = clusterSource.getSource
197
575
  ? clusterSource.getSource()
198
576
  : source;
199
577
  vectorSource?.clear();
200
578
  }
201
- /**
202
- * Updates the features of a vector layer.
203
- * Syncs new features without clearing existing ones (preserves OL modifications).
204
- * @param id - Layer identifier
205
- * @param features - New features to sync
206
- */
207
579
  updateFeatures(id, features) {
208
580
  const layer = this.layerCache.get(id);
209
581
  if (!(layer instanceof VectorLayer))
@@ -211,65 +583,32 @@ class OlLayerService {
211
583
  const source = layer.getSource();
212
584
  if (!source)
213
585
  return;
214
- // Handle Cluster source: get the underlying VectorSource
215
586
  const clusterSource = source;
216
587
  const vectorSource = clusterSource.getSource
217
588
  ? clusterSource.getSource()
218
589
  : source;
219
590
  if (!(vectorSource instanceof VectorSource))
220
591
  return;
221
- // Sync features: remove old ones, update existing ones, add new ones
222
592
  if (features) {
223
593
  const newFeatureIds = new Set(features.map((f) => f.id));
224
594
  const sourceFeatures = vectorSource.getFeatures();
225
- // 1. Remove features that are no longer in the input
226
595
  sourceFeatures.forEach((f) => {
227
- const id = f.getId();
228
- if (id !== undefined && !newFeatureIds.has(id)) {
596
+ const fId = f.getId();
597
+ if (fId !== undefined && !newFeatureIds.has(fId)) {
229
598
  vectorSource.removeFeature(f);
230
599
  }
231
600
  });
232
- // 2. Only add features that don't already exist in the source
233
601
  const existingIds = new Set(vectorSource
234
602
  .getFeatures()
235
603
  .map((f) => f.getId())
236
- .filter((id) => id !== undefined));
604
+ .filter((fId) => fId !== undefined));
237
605
  const featuresToAdd = features.filter((f) => !existingIds.has(f.id));
238
606
  if (featuresToAdd.length > 0) {
239
- const olFeatures = featuresToAdd.map((feature) => {
240
- const geom = feature.geometry;
241
- let geometry;
242
- if (!geom.coordinates) {
243
- geometry = new Point([0, 0]);
244
- }
245
- else if (geom.type === 'Point') {
246
- const coords = geom.coordinates;
247
- geometry = new Point(fromLonLat(coords));
248
- }
249
- else if (geom.type === 'LineString') {
250
- const coords = geom.coordinates.map((c) => fromLonLat(c));
251
- geometry = new LineString(coords);
252
- }
253
- else if (geom.type === 'Polygon') {
254
- const rings = geom.coordinates.map((ring) => ring.map((c) => fromLonLat(c)));
255
- geometry = new Polygon(rings);
256
- }
257
- else if (geom.type === 'Circle') {
258
- const center = fromLonLat(geom.coordinates);
259
- geometry = new Circle(center, geom.radius ?? 1000);
260
- }
261
- else {
262
- geometry = new Point([0, 0]);
263
- }
264
- const olFeature = new OLFeature({
265
- geometry,
266
- ...feature.properties,
267
- });
268
- if (feature.style) {
269
- olFeature.set(STYLE_PROP, feature.style);
270
- }
271
- olFeature.setId(feature.id);
272
- return olFeature;
607
+ const olFeatures = featuresToAdd.map((f) => {
608
+ const olf = featureToOlFeature(f);
609
+ if (f.style)
610
+ olf.set(STYLE_PROP, f.style);
611
+ return olf;
273
612
  });
274
613
  vectorSource.addFeatures(olFeatures);
275
614
  }
@@ -295,285 +634,6 @@ class OlLayerService {
295
634
  });
296
635
  this.layerState.set(layers.sort((a, b) => a.zIndex - b.zIndex));
297
636
  }
298
- createVectorLayer(config, map) {
299
- const sourceOptions = {};
300
- if (config.url && config.format) {
301
- sourceOptions.url = config.url;
302
- if (config.format === 'geojson')
303
- sourceOptions.format = new GeoJSON();
304
- else if (config.format === 'topojson')
305
- sourceOptions.format = new TopoJSON();
306
- else if (config.format === 'kml')
307
- sourceOptions.format = new KML();
308
- }
309
- const vectorSource = new VectorSource(sourceOptions);
310
- // Add features if provided
311
- if (config.features && config.features.length > 0) {
312
- const olFeatures = config.features.map((feature) => {
313
- const geom = feature.geometry;
314
- let geometry;
315
- // Validate coordinates exist before processing
316
- if (!geom.coordinates) {
317
- geometry = new Point([0, 0]);
318
- }
319
- else if (geom.type === 'Point') {
320
- // Transform from EPSG:4326 (lon/lat) to EPSG:3857 (map projection)
321
- const coords = geom.coordinates;
322
- geometry = new Point(fromLonLat(coords));
323
- }
324
- else if (geom.type === 'LineString') {
325
- const coords = geom.coordinates.map((c) => fromLonLat(c));
326
- geometry = new LineString(coords);
327
- }
328
- else if (geom.type === 'Polygon') {
329
- const rings = geom.coordinates.map((ring) => ring.map((c) => fromLonLat(c)));
330
- geometry = new Polygon(rings);
331
- }
332
- else if (geom.type === 'Circle') {
333
- const center = fromLonLat(geom.coordinates);
334
- geometry = new Circle(center, geom.radius ?? 1000);
335
- }
336
- else {
337
- geometry = new Point([0, 0]);
338
- }
339
- const olFeature = new OLFeature({
340
- geometry,
341
- ...feature.properties,
342
- });
343
- if (feature.style) {
344
- olFeature.set(STYLE_PROP, feature.style);
345
- }
346
- olFeature.setId(feature.id);
347
- return olFeature;
348
- });
349
- vectorSource.addFeatures(olFeatures);
350
- }
351
- // Wrap in cluster source if enabled
352
- const clusterCfg = config.cluster;
353
- const source = clusterCfg?.enabled
354
- ? new ClusterSource({
355
- source: vectorSource,
356
- distance: clusterCfg.distance ?? 40,
357
- minDistance: clusterCfg.minDistance ?? 20,
358
- geometryFunction: (feature) => {
359
- const geometry = feature.getGeometry();
360
- if (!geometry)
361
- return null;
362
- // For Point geometries, use as-is
363
- if (geometry.getType() === 'Point') {
364
- return geometry;
365
- }
366
- // For other geometries (Polygon, Circle, etc.), use center point
367
- const extent = geometry.getExtent();
368
- const center = getCenter(extent);
369
- return new Point(center);
370
- },
371
- })
372
- : vectorSource;
373
- // Default style for all geometry types (points, lines, polygons)
374
- const defaultStyle = new Style({
375
- fill: new Fill({ color: 'rgba(25, 118, 210, 0.3)' }),
376
- stroke: new Stroke({ color: '#1976d2', width: 2 }),
377
- image: new Circle$1({
378
- radius: 8,
379
- fill: new Fill({ color: '#1976d2' }),
380
- stroke: new Stroke({ color: '#d32f2f', width: 2 }),
381
- }),
382
- });
383
- // Resolved style: priority to stashed metadata, then config-level style, then default.
384
- const userStyle = config.style;
385
- const styleFn = (olFeature, resolution) => {
386
- // 1. Per-feature style metadata (stashed via STYLE_PROP)
387
- const abstractStyle = olFeature.get(STYLE_PROP);
388
- if (abstractStyle) {
389
- const style = new Style();
390
- const { icon, fill, stroke } = abstractStyle;
391
- if (icon?.src) {
392
- style.setImage(new Icon({
393
- src: icon.src,
394
- ...(icon.size ? { size: icon.size } : {}),
395
- ...(icon.anchor ? { anchor: icon.anchor } : {}),
396
- }));
397
- }
398
- if (fill) {
399
- style.setFill(new Fill({ color: fill.color }));
400
- }
401
- if (stroke) {
402
- style.setStroke(new Stroke({ color: stroke.color, width: stroke.width }));
403
- }
404
- if (icon?.src || fill || stroke)
405
- return style;
406
- }
407
- // 2. Layer-level style from config (supports functions or static styles)
408
- if (userStyle) {
409
- if (typeof userStyle === 'function') {
410
- // Check if it's already an OL native feature or wrap if needed
411
- // For simplicity in the demo, we pass the feature as-is or mapped
412
- const feature = {
413
- id: String(olFeature.getId() ?? ''),
414
- geometry: {
415
- type: olFeature.getGeometry()?.getType(),
416
- coordinates: [], // coordinates not easily reversible without extra work
417
- },
418
- properties: olFeature.getProperties(),
419
- };
420
- return userStyle(feature, resolution);
421
- }
422
- return userStyle;
423
- }
424
- return defaultStyle;
425
- };
426
- // Cluster style: shows count badge when features are clustered, else delegates to styleFn
427
- const clusterStyleFn = (olFeature, resolution) => {
428
- const features = olFeature.get('features');
429
- const size = features?.length ?? 1;
430
- if (size > 1) {
431
- const showCount = clusterCfg?.showCount ?? true;
432
- return new Style({
433
- image: new Circle$1({
434
- radius: 15 + Math.min(size * 2, 15),
435
- fill: new Fill({ color: 'rgba(255, 100, 100, 0.8)' }),
436
- stroke: new Stroke({ color: '#fff', width: 2 }),
437
- }),
438
- text: showCount
439
- ? new Text({
440
- text: String(size),
441
- fill: new Fill({ color: '#fff' }),
442
- })
443
- : undefined,
444
- });
445
- }
446
- // Single feature in cluster: unwrap and call styleFn
447
- const originalFeature = features?.[0];
448
- if (originalFeature) {
449
- // We MUST preserve the original geometry if it's a non-point ( spiderfication etc)
450
- const style = styleFn(originalFeature, resolution);
451
- if (style instanceof Style) {
452
- const origGeom = originalFeature.getGeometry();
453
- if (origGeom)
454
- style.setGeometry(origGeom);
455
- }
456
- return style;
457
- }
458
- return styleFn(olFeature, resolution);
459
- };
460
- const layer = new VectorLayer({
461
- source,
462
- visible: config.visible ?? true,
463
- opacity: config.opacity ?? 1,
464
- zIndex: config.zIndex,
465
- style: clusterCfg?.enabled ? clusterStyleFn : styleFn,
466
- });
467
- layer.set('id', config.id);
468
- map.addLayer(layer);
469
- this.layerCache.set(config.id, layer);
470
- this.updateLayerState();
471
- return { id: config.id };
472
- }
473
- createHeatmapLayer(config, map) {
474
- const vectorSource = new VectorSource();
475
- if (config.features && config.features.length > 0) {
476
- const olFeatures = config.features.map((feature) => {
477
- const geom = feature.geometry;
478
- let geometry;
479
- if (!geom.coordinates) {
480
- geometry = new Point([0, 0]);
481
- }
482
- else if (geom.type === 'Point') {
483
- const coords = geom.coordinates;
484
- geometry = new Point(fromLonLat(coords));
485
- }
486
- else if (geom.type === 'LineString') {
487
- const coords = geom.coordinates.map((c) => fromLonLat(c));
488
- geometry = new LineString(coords);
489
- }
490
- else if (geom.type === 'Polygon') {
491
- const rings = geom.coordinates.map((ring) => ring.map((c) => fromLonLat(c)));
492
- geometry = new Polygon(rings);
493
- }
494
- else {
495
- geometry = new Point([0, 0]);
496
- }
497
- const olFeature = new OLFeature({
498
- geometry,
499
- ...feature.properties,
500
- });
501
- olFeature.setId(feature.id);
502
- return olFeature;
503
- });
504
- vectorSource.addFeatures(olFeatures);
505
- }
506
- const layer = new HeatmapLayer({
507
- source: vectorSource,
508
- visible: config.visible ?? true,
509
- opacity: config.opacity ?? 1,
510
- zIndex: config.zIndex,
511
- ...(config.blur !== undefined && { blur: config.blur }),
512
- ...(config.radius !== undefined && { radius: config.radius }),
513
- ...(config.weight !== undefined && {
514
- weight: config.weight,
515
- }),
516
- });
517
- layer.set('id', config.id);
518
- map.addLayer(layer);
519
- this.layerCache.set(config.id, layer);
520
- this.updateLayerState();
521
- return { id: config.id };
522
- }
523
- createTileLayer(config, map) {
524
- let source;
525
- switch (config.source.type) {
526
- case 'osm':
527
- source = new OSM({ attributions: config.source.attributions });
528
- break;
529
- case 'xyz':
530
- source = new XYZ({ url: config.source.url, attributions: config.source.attributions });
531
- break;
532
- case 'wms':
533
- source = new TileWMS({
534
- url: config.source.url,
535
- params: config.source.params ?? {},
536
- attributions: config.source.attributions,
537
- });
538
- break;
539
- default:
540
- source = new OSM();
541
- }
542
- const layer = new TileLayer({
543
- source,
544
- visible: config.visible ?? true,
545
- opacity: config.opacity ?? 1,
546
- zIndex: config.zIndex,
547
- });
548
- layer.set('id', config.id);
549
- map.addLayer(layer);
550
- this.layerCache.set(config.id, layer);
551
- this.updateLayerState();
552
- return { id: config.id };
553
- }
554
- createImageLayer(config, map) {
555
- let source;
556
- if (config.source.type === 'static') {
557
- source = new ImageStatic({
558
- url: config.source.url,
559
- imageExtent: config.source.imageExtent ?? [0, 0, 1, 1],
560
- });
561
- }
562
- else {
563
- source = new ImageWMS({ url: config.source.url, params: config.source.params });
564
- }
565
- const layer = new ImageLayer({
566
- source,
567
- visible: config.visible ?? true,
568
- opacity: config.opacity ?? 1,
569
- zIndex: config.zIndex,
570
- });
571
- layer.set('id', config.id);
572
- map.addLayer(layer);
573
- this.layerCache.set(config.id, layer);
574
- this.updateLayerState();
575
- return { id: config.id };
576
- }
577
637
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlLayerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
578
638
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlLayerService });
579
639
  }
@@ -586,8 +646,10 @@ class OlClusterComponent {
586
646
  minDistance = input(20, ...(ngDevMode ? [{ debugName: "minDistance" }] : /* istanbul ignore next */ []));
587
647
  showCount = input(true, ...(ngDevMode ? [{ debugName: "showCount" }] : /* istanbul ignore next */ []));
588
648
  featureStyle = input(...(ngDevMode ? [undefined, { debugName: "featureStyle" }] : /* istanbul ignore next */ []));
649
+ spiderfyOnSelect = input(false, ...(ngDevMode ? [{ debugName: "spiderfyOnSelect" }] : /* istanbul ignore next */ []));
650
+ spiderfyClick = output();
589
651
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlClusterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
590
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.13", type: OlClusterComponent, isStandalone: true, selector: "ol-cluster", inputs: { distance: { classPropertyName: "distance", publicName: "distance", isSignal: true, isRequired: false, transformFunction: null }, minDistance: { classPropertyName: "minDistance", publicName: "minDistance", isSignal: true, isRequired: false, transformFunction: null }, showCount: { classPropertyName: "showCount", publicName: "showCount", isSignal: true, isRequired: false, transformFunction: null }, featureStyle: { classPropertyName: "featureStyle", publicName: "featureStyle", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
652
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.13", type: OlClusterComponent, isStandalone: true, selector: "ol-cluster", inputs: { distance: { classPropertyName: "distance", publicName: "distance", isSignal: true, isRequired: false, transformFunction: null }, minDistance: { classPropertyName: "minDistance", publicName: "minDistance", isSignal: true, isRequired: false, transformFunction: null }, showCount: { classPropertyName: "showCount", publicName: "showCount", isSignal: true, isRequired: false, transformFunction: null }, featureStyle: { classPropertyName: "featureStyle", publicName: "featureStyle", isSignal: true, isRequired: false, transformFunction: null }, spiderfyOnSelect: { classPropertyName: "spiderfyOnSelect", publicName: "spiderfyOnSelect", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { spiderfyClick: "spiderfyClick" }, ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
591
653
  }
592
654
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: OlClusterComponent, decorators: [{
593
655
  type: Component,
@@ -596,7 +658,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
596
658
  template: '',
597
659
  changeDetection: ChangeDetectionStrategy.OnPush,
598
660
  }]
599
- }], propDecorators: { distance: [{ type: i0.Input, args: [{ isSignal: true, alias: "distance", required: false }] }], minDistance: [{ type: i0.Input, args: [{ isSignal: true, alias: "minDistance", required: false }] }], showCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCount", required: false }] }], featureStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "featureStyle", required: false }] }] } });
661
+ }], propDecorators: { distance: [{ type: i0.Input, args: [{ isSignal: true, alias: "distance", required: false }] }], minDistance: [{ type: i0.Input, args: [{ isSignal: true, alias: "minDistance", required: false }] }], showCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCount", required: false }] }], featureStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "featureStyle", required: false }] }], spiderfyOnSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "spiderfyOnSelect", required: false }] }], spiderfyClick: [{ type: i0.Output, args: ["spiderfyClick"] }] } });
600
662
 
601
663
  // OlVectorLayerComponent
602
664
  class OlVectorLayerComponent {
@@ -624,6 +686,8 @@ class OlVectorLayerComponent {
624
686
  minDistance: clusterCmp.minDistance(),
625
687
  showCount: clusterCmp.showCount(),
626
688
  featureStyle: clusterCmp.featureStyle(),
689
+ spiderfyOnSelect: clusterCmp.spiderfyOnSelect(),
690
+ onSpiderfyClick: (f) => clusterCmp.spiderfyClick.emit(f),
627
691
  }
628
692
  : undefined);
629
693
  this.layerService.addLayer({
@@ -656,6 +720,17 @@ class OlVectorLayerComponent {
656
720
  effect(() => {
657
721
  this.layerService.setZIndex(this.id(), this.zIndex());
658
722
  });
723
+ // Reactive cluster distance updates
724
+ effect(() => {
725
+ const clusterCmp = this.clusterComponent();
726
+ if (clusterCmp) {
727
+ const dist = clusterCmp.distance();
728
+ const minDst = clusterCmp.minDistance();
729
+ // Since we are inside effect, these will trigger when distance changes
730
+ this.layerService.setClusterDistance(this.id(), dist);
731
+ this.layerService.setClusterMinDistance(this.id(), minDst);
732
+ }
733
+ });
659
734
  // Cleanup when component is destroyed
660
735
  this.destroyRef.onDestroy(() => {
661
736
  this.layerService.removeLayer(this.id());
@@ -963,6 +1038,16 @@ class OlWebGLVectorLayerComponent {
963
1038
  }
964
1039
  });
965
1040
  }
1041
+ /**
1042
+ * Imperatively update style variables without triggering Angular change detection.
1043
+ * Ideal for 60FPS animations (e.g., linked to OlTimeService) where you don't want
1044
+ * to use the declarative [variables] input.
1045
+ */
1046
+ updateVariables(vars) {
1047
+ if (this.layer) {
1048
+ this.layer.updateStyleVariables(vars);
1049
+ }
1050
+ }
966
1051
  syncFeatures(features) {
967
1052
  this.vectorSource.clear();
968
1053
  if (!features?.length)
@@ -984,7 +1069,7 @@ class OlWebGLVectorLayerComponent {
984
1069
  }
985
1070
  else if (geom.type === 'Circle') {
986
1071
  const center = fromLonLat(geom.coordinates);
987
- geometry = new Circle(center, geom.radius ?? 1000);
1072
+ geometry = new Circle$1(center, geom.radius ?? 1000);
988
1073
  }
989
1074
  else {
990
1075
  geometry = new Point([0, 0]);