• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geonetwork / geonetwork-ui / 19697323873

26 Nov 2025 08:30AM UTC coverage: 84.522% (-0.003%) from 84.525%
19697323873

push

github

web-flow
Merge pull request #1403 from geonetwork/dh-add-map-inside-stac-view

[Datahub]: Add interactive map filter to STAC viewer

4029 of 5319 branches covered (75.75%)

Branch coverage included in aggregate %.

92 of 114 new or added lines in 6 files covered. (80.7%)

6 existing lines in 2 files now uncovered.

11097 of 12577 relevant lines covered (88.23%)

346.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.6
/libs/ui/map/src/lib/components/map-container/map-container.component.ts
1
import {
2✔
2
  AfterViewInit,
3
  ChangeDetectionStrategy,
4
  Component,
5
  DestroyRef,
6
  ElementRef,
7
  EventEmitter,
8
  Inject,
9
  inject,
10
  Input,
11
  OnChanges,
12
  Output,
13
  SimpleChanges,
14
  ViewChild,
15
} from '@angular/core'
16
import { fromEvent, merge, Observable, of, timer } from 'rxjs'
2✔
17
import { delay, map, startWith, switchMap } from 'rxjs/operators'
2✔
18
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
2✔
19
import { CommonModule } from '@angular/common'
2✔
20
import { TranslateDirective } from '@ngx-translate/core'
2✔
21
import {
2✔
22
  computeMapContextDiff,
23
  Extent,
24
  FeaturesClickEvent,
25
  FeaturesClickEventType,
26
  FeaturesHoverEvent,
27
  FeaturesHoverEventType,
28
  MapClickEvent,
29
  MapClickEventType,
30
  MapExtentChangeEvent,
31
  MapExtentChangeEventType,
32
  MapContext,
33
  MapContextLayer,
34
  MapContextLayerXyz,
35
  MapContextView,
36
  MapEventsByType,
37
  SourceLoadErrorEvent,
38
  SourceLoadErrorType,
39
} from '@geospatial-sdk/core'
40
import {
2✔
41
  applyContextDiffToMap,
42
  createMapFromContext,
43
  listen,
44
} from '@geospatial-sdk/openlayers'
45
import type OlMap from 'ol/Map'
46
import type { Feature } from 'geojson'
47
import {
2✔
48
  BASEMAP_LAYERS,
49
  DO_NOT_USE_DEFAULT_BASEMAP,
50
  MAP_VIEW_CONSTRAINTS,
51
} from './map-settings.token'
52
import {
2✔
53
  NgIconComponent,
54
  provideIcons,
55
  provideNgIconsConfig,
56
} from '@ng-icons/core'
57
import { matSwipeOutline } from '@ng-icons/material-icons/outline'
2✔
58
import { transformExtent } from 'ol/proj'
2✔
59

60
const DEFAULT_BASEMAP_LAYER: MapContextLayerXyz = {
2✔
61
  type: 'xyz',
62
  url: `https://{a-c}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png`,
63
  attributions: `<span>© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <a href="https://carto.com/">Carto</a></span>`,
64
}
65

66
const DEFAULT_VIEW: MapContextView = {
2✔
67
  center: [0, 15],
68
  zoom: 2,
69
}
70

71
interface MapViewConstraints {
72
  maxZoom?: number
73
  maxExtent?: Extent
74
}
75

76
@Component({
77
  selector: 'gn-ui-map-container',
78
  templateUrl: './map-container.component.html',
79
  styleUrls: ['./map-container.component.css'],
80
  changeDetection: ChangeDetectionStrategy.OnPush,
81
  standalone: true,
82
  imports: [CommonModule, TranslateDirective, NgIconComponent],
83
  providers: [
84
    provideIcons({ matSwipeOutline }),
85
    provideNgIconsConfig({
86
      size: '1.5em',
87
    }),
88
  ],
89
})
90
export class MapContainerComponent implements AfterViewInit, OnChanges {
2✔
91
  @Input() context: MapContext | null
92

93
  @ViewChild('map') container: ElementRef
94

95
  private olMap: OlMap
96
  private olMapResolver: (value: OlMap) => void
97
  private destroyRef: DestroyRef
98

99
  displayMessage$: Observable<boolean>
100
  openlayersMap = new Promise<OlMap>((resolve) => {
22✔
101
    this.olMapResolver = resolve
22✔
102
  })
103

104
  // These events only get registered on the map if they are used
105
  _featuresClick: EventEmitter<Feature[]> = null
22✔
106
  _featuresHover: EventEmitter<Feature[]> = null
22✔
107
  _mapClick: EventEmitter<[number, number]> = null
22✔
108
  _extentChange: EventEmitter<Extent> = null
22✔
109
  _sourceLoadError: EventEmitter<SourceLoadErrorEvent> = null
22✔
110
  _resolvedExtentChange: EventEmitter<Extent> = null
22✔
111

112
  @Output() get featuresClick() {
113
    if (!this._featuresClick) {
×
NEW
114
      this.setupEventListener(
×
115
        FeaturesClickEventType,
116
        (event: FeaturesClickEvent) => {
NEW
117
          this._featuresClick.emit(event.features)
×
118
        }
119
      )
UNCOV
120
      this._featuresClick = new EventEmitter<Feature[]>()
×
121
    }
122
    return this._featuresClick
×
123
  }
124

125
  @Output() get featuresHover() {
126
    if (!this._featuresHover) {
×
NEW
127
      this.setupEventListener(
×
128
        FeaturesHoverEventType,
129
        (event: FeaturesHoverEvent) => {
NEW
130
          this._featuresHover.emit(event.features)
×
131
        }
132
      )
UNCOV
133
      this._featuresHover = new EventEmitter<Feature[]>()
×
134
    }
135
    return this._featuresHover
×
136
  }
137

138
  @Output() get mapClick() {
139
    if (!this._mapClick) {
×
NEW
140
      this.setupEventListener(MapClickEventType, (event: MapClickEvent) => {
×
NEW
141
        this._mapClick.emit(event.coordinate)
×
142
      })
143
      this._mapClick = new EventEmitter<[number, number]>()
×
144
    }
145
    return this._mapClick
×
146
  }
147

148
  @Output() get extentChange() {
NEW
149
    if (!this._extentChange) {
×
NEW
150
      this.setupEventListener(
×
151
        MapExtentChangeEventType,
152
        (event: MapExtentChangeEvent) => {
NEW
153
          this._extentChange.emit(event.extent as Extent)
×
154
        }
155
      )
NEW
156
      this._extentChange = new EventEmitter<Extent>()
×
157
    }
NEW
158
    return this._extentChange
×
159
  }
160

161
  @Output() get sourceLoadError() {
162
    if (!this._sourceLoadError) {
×
NEW
163
      this.setupEventListener(
×
164
        SourceLoadErrorType,
165
        (event: SourceLoadErrorEvent) => {
NEW
166
          this._sourceLoadError.emit(event)
×
167
        }
168
      )
UNCOV
169
      this._sourceLoadError = new EventEmitter<SourceLoadErrorEvent>()
×
170
    }
171
    return this._sourceLoadError
×
172
  }
173

174
  @Output() get resolvedExtentChange() {
175
    if (!this._resolvedExtentChange) {
3✔
176
      this._resolvedExtentChange = new EventEmitter<Extent>()
3✔
177
    }
178
    return this._resolvedExtentChange
3✔
179
  }
180

181
  constructor(
182
    @Inject(DO_NOT_USE_DEFAULT_BASEMAP) private doNotUseDefaultBasemap: boolean,
22✔
183
    @Inject(BASEMAP_LAYERS) private basemapLayers: MapContextLayer[],
22✔
184
    @Inject(MAP_VIEW_CONSTRAINTS)
185
    private mapViewConstraints: MapViewConstraints
22✔
186
  ) {
187
    this.destroyRef = inject(DestroyRef)
22✔
188
  }
189

190
  calculateCurrentMapExtent(): Extent {
191
    const extent = this.olMap.getView().calculateExtent(this.olMap.getSize())
4✔
192
    const reprojectedExtent = transformExtent(
4✔
193
      extent,
194
      this.olMap.getView().getProjection(),
195
      'EPSG:4326'
196
    )
197

198
    return reprojectedExtent as Extent
4✔
199
  }
200

201
  async ngAfterViewInit() {
202
    this.olMap = await createMapFromContext(
33✔
203
      this.processContext(this.context),
204
      this.container.nativeElement
205
    )
206
    if (this._resolvedExtentChange) {
33✔
207
      this._resolvedExtentChange.emit(this.calculateCurrentMapExtent())
3✔
208
    }
209

210
    this.setupDisplayMessageObservable()
33✔
211
    this.olMapResolver(this.olMap)
33✔
212
  }
213

214
  async ngOnChanges(changes: SimpleChanges) {
215
    if ('context' in changes && !changes['context'].isFirstChange()) {
4✔
216
      const diff = computeMapContextDiff(
4✔
217
        this.processContext(changes['context'].currentValue),
218
        this.processContext(changes['context'].previousValue)
219
      )
220
      await applyContextDiffToMap(this.olMap, diff)
4✔
221

222
      if (this._resolvedExtentChange && diff.viewChanges) {
4✔
223
        this._resolvedExtentChange.emit(this.calculateCurrentMapExtent())
1✔
224
      }
225
    }
226
  }
227

228
  private setupEventListener(
229
    eventType: keyof MapEventsByType,
230
    handler: (event: MapEventsByType[typeof eventType]) => void
231
  ) {
NEW
232
    this.openlayersMap.then((olMap: OlMap) => {
×
NEW
233
      listen(olMap, eventType, handler)
×
234
    })
235
  }
236

237
  private setupDisplayMessageObservable() {
238
    this.displayMessage$ = merge(
33✔
239
      fromEvent(this.olMap, 'mapmuted').pipe(map(() => true)),
2✔
240
      fromEvent(this.olMap, 'movestart').pipe(map(() => false)),
1✔
241
      fromEvent(this.olMap, 'singleclick').pipe(map(() => false))
1✔
242
    ).pipe(
243
      switchMap((muted) =>
244
        muted
4✔
245
          ? timer(2000).pipe(
246
              map(() => false),
1✔
247
              startWith(true),
248
              delay(400)
249
            )
250
          : of(false)
251
      ),
252
      takeUntilDestroyed(this.destroyRef)
253
    )
254
  }
255

256
  private processContext(context: MapContext): MapContext {
257
    const processed = context
46✔
258
      ? { ...context, view: context.view ?? DEFAULT_VIEW }
16✔
259
      : { layers: [], view: DEFAULT_VIEW }
260

261
    // Prepend with default basemap and basemap layers
262
    if (this.basemapLayers.length) {
46✔
263
      processed.layers = [...this.basemapLayers, ...processed.layers]
1✔
264
    }
265
    if (!this.doNotUseDefaultBasemap) {
46✔
266
      processed.layers = [DEFAULT_BASEMAP_LAYER, ...processed.layers]
45✔
267
    }
268

269
    // Apply view constraints
270
    if (this.mapViewConstraints.maxZoom) {
46✔
271
      processed.view = {
1✔
272
        maxZoom: this.mapViewConstraints.maxZoom,
273
        ...processed.view,
274
      }
275
    }
276
    if (this.mapViewConstraints.maxExtent) {
46✔
277
      processed.view = {
1✔
278
        maxExtent: this.mapViewConstraints.maxExtent,
279
        ...processed.view,
280
      }
281
    }
282

283
    if (
46✔
284
      processed.view &&
138✔
285
      'zoom' in processed.view &&
286
      'center' in processed.view
287
    ) {
288
      return processed
46✔
289
    }
290

NEW
291
    if (processed.view && 'extent' in processed.view) {
×
NEW
292
      return processed
×
293
    }
294

295
    // Ensure valid view
NEW
296
    if (this.mapViewConstraints.maxExtent) {
×
NEW
297
      processed.view = {
×
298
        extent: this.mapViewConstraints.maxExtent,
299
        ...processed.view,
300
      }
301
    } else {
NEW
302
      processed.view = { ...DEFAULT_VIEW, ...processed.view }
×
303
    }
304

UNCOV
305
    return processed
×
306
  }
307
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc