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

geonetwork / geonetwork-ui / 18585591321

17 Oct 2025 07:21AM UTC coverage: 83.976% (-0.5%) from 84.489%
18585591321

Pull #1373

github

web-flow
Merge c98b9be91 into c06b26576
Pull Request #1373: Datahub: Display and sort by resource date

3614 of 4838 branches covered (74.7%)

Branch coverage included in aggregate %.

17 of 24 new or added lines in 8 files covered. (70.83%)

5 existing lines in 2 files now uncovered.

10342 of 11781 relevant lines covered (87.79%)

265.99 hits per line

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

94.55
/libs/feature/record/src/lib/map-view/map-view.component.ts
1
import {
1✔
2
  AfterViewInit,
3
  ChangeDetectionStrategy,
4
  ChangeDetectorRef,
5
  Component,
6
  EventEmitter,
7
  Input,
8
  Output,
9
  ViewChild,
10
} from '@angular/core'
11
import { MapUtilsService } from '@geonetwork-ui/feature/map'
1✔
12
import { getLinkId, getLinkLabel } from '@geonetwork-ui/util/shared'
1✔
13
import {
1✔
14
  BehaviorSubject,
15
  combineLatest,
16
  from,
17
  Observable,
18
  of,
19
  startWith,
20
  throwError,
21
  withLatestFrom,
22
} from 'rxjs'
23
import {
1✔
24
  catchError,
25
  distinctUntilChanged,
26
  finalize,
27
  map,
28
  shareReplay,
29
  switchMap,
30
  take,
31
  tap,
32
} from 'rxjs/operators'
33
import { MdViewFacade } from '../state/mdview.facade'
1✔
34
import { DataService } from '@geonetwork-ui/feature/dataviz'
1✔
35
import {
36
  DatasetOnlineResource,
37
  DatasetServiceDistribution,
38
} from '@geonetwork-ui/common/domain/model/record'
39
import {
1✔
40
  createViewFromLayer,
41
  MapContext,
42
  MapContextLayer,
43
  SourceLoadErrorEvent,
44
} from '@geospatial-sdk/core'
45
import {
1✔
46
  FeatureDetailComponent,
47
  MapContainerComponent,
48
  MapLegendComponent,
49
  prioritizePageScroll,
50
} from '@geonetwork-ui/ui/map'
51
import { Feature } from 'geojson'
52
import {
1✔
53
  NgIconComponent,
54
  provideIcons,
55
  provideNgIconsConfig,
56
} from '@ng-icons/core'
57
import { matClose } from '@ng-icons/material-icons/baseline'
1✔
58
import { CommonModule } from '@angular/common'
1✔
59
import {
1✔
60
  ButtonComponent,
61
  DropdownSelectorComponent,
62
} from '@geonetwork-ui/ui/inputs'
63
import {
1✔
64
  TranslateDirective,
65
  TranslatePipe,
66
  TranslateService,
67
} from '@ngx-translate/core'
68
import { ExternalViewerButtonComponent } from '../external-viewer-button/external-viewer-button.component'
1✔
69
import {
1✔
70
  LoadingMaskComponent,
71
  PopupAlertComponent,
72
} from '@geonetwork-ui/ui/widgets'
73
import { marker } from '@biesbjerg/ngx-translate-extract-marker'
1✔
74
import { FetchError } from '@geonetwork-ui/data-fetcher'
1✔
75

76
marker('map.dropdown.placeholder')
1✔
77
marker('wfs.feature.limit')
1✔
78
marker('dataset.error.restrictedAccess')
1✔
79
marker('map.select.style')
1✔
80

81
@Component({
82
  selector: 'gn-ui-map-view',
83
  templateUrl: './map-view.component.html',
84
  styleUrls: ['./map-view.component.css'],
85
  changeDetection: ChangeDetectionStrategy.OnPush,
86
  standalone: true,
87
  imports: [
88
    CommonModule,
89
    DropdownSelectorComponent,
90
    MapContainerComponent,
91
    FeatureDetailComponent,
92
    PopupAlertComponent,
93
    TranslateDirective,
94
    TranslatePipe,
95
    LoadingMaskComponent,
96
    NgIconComponent,
97
    ExternalViewerButtonComponent,
98
    ButtonComponent,
99
    MapLegendComponent,
100
  ],
101
  viewProviders: [
102
    provideIcons({ matClose }),
103
    provideNgIconsConfig({
104
      size: '1.5em',
105
    }),
106
  ],
107
})
108
export class MapViewComponent implements AfterViewInit {
1✔
109
  @Input() set exceedsLimit(value: boolean) {
110
    this.excludeWfs$.next(value)
×
111
  }
112
  linkFromConfig$ = new BehaviorSubject(null)
61✔
113
  _selectedChoice = null
61✔
114
  _styleFromConfig = null
61✔
115

116
  linkMap: Map<string, DatasetOnlineResource> = new Map()
61✔
117
  // FIXME the map view component should not need a selectedView
118
  @Input() set selectedView(value: string) {
119
    this.selectedView$.next(value)
1✔
120
  }
121
  @Input() set datavizConfig(value: any) {
122
    if (value && value.view === 'map') {
2✔
123
      this.selectedView$.next(value.view)
2✔
124
      if (value.styleTMSIndex) {
2✔
125
        this._styleFromConfig = value.styleTMSIndex
1✔
126
      }
127
      if (value.source) {
2✔
128
        this.linkFromConfig$.next(value.source)
2✔
129
      }
130
    }
131
  }
132
  @Input() displaySource = true
61✔
133
  @Output() linkSelected = new EventEmitter<DatasetOnlineResource>()
61✔
134
  @Output() styleSelected = new EventEmitter<number>()
61✔
135
  @ViewChild('mapContainer') mapContainer: MapContainerComponent
136

137
  excludeWfs$ = new BehaviorSubject(false)
61✔
138
  hidePreview = false
61✔
139
  selection: Feature
140
  showLegend = true
61✔
141
  legendExists = false
61✔
142
  loading = false
61✔
143
  error = null
61✔
144

145
  selectLinkToDisplay(id: string) {
146
    this.selectedLinkId$.next(id)
15✔
147
  }
148

149
  selectStyleToDisplay(i: number) {
150
    this.selectedStyleId$.next(i)
2✔
151
    this.styleSelected.emit(i)
2✔
152
  }
153

154
  toggleLegend() {
UNCOV
155
    this.showLegend = !this.showLegend
×
156
  }
157
  onLegendStatusChange(v: boolean) {
158
    this.legendExists = v
1✔
159
  }
160

161
  compatibleMapLinks$ = combineLatest([
61✔
162
    this.mdViewFacade.mapApiLinks$,
163
    this.mdViewFacade.geoDataLinksWithGeometry$,
164
  ]).pipe(
165
    map(([mapApiLinks, geoDataLinksWithGeometry]) => [
51✔
166
      ...mapApiLinks,
167
      ...geoDataLinksWithGeometry,
168
    ]),
169
    shareReplay(1)
170
  )
171

172
  dropdownChoices$ = this.compatibleMapLinks$.pipe(
61✔
173
    map((links) => {
174
      this.linkMap.clear()
51✔
175
      links.forEach((link: DatasetOnlineResource) =>
51✔
176
        this.linkMap.set(getLinkId(link), link)
90✔
177
      )
178
      return links.length
51✔
179
        ? links.map((link) => ({
90✔
180
            label: getLinkLabel(link),
181
            value: getLinkId(link),
182
          }))
183
        : [{ label: 'map.dropdown.placeholder', value: '' }]
184
    })
185
  )
186

187
  selectedView$ = new BehaviorSubject(null)
61✔
188
  selectedLinkId$ = new BehaviorSubject(null)
61✔
189
  selectedStyleId$ = new BehaviorSubject(null)
61✔
190

191
  selectedSourceLink$ = combineLatest([
61✔
192
    this.compatibleMapLinks$,
193
    this.linkFromConfig$,
194
    this.selectedLinkId$.pipe(distinctUntilChanged()),
195
    this.selectedView$,
196
  ]).pipe(
197
    tap(() => {
198
      this.error = null
140✔
199
    }),
200
    map(([compatibleLinks, configLink, id, view]) => {
201
      if (view === 'map') {
140✔
202
        if (
140✔
203
          configLink &&
148✔
204
          !id &&
205
          compatibleLinks.some(
206
            (link) => getLinkId(link) === getLinkId(configLink)
6✔
207
          )
208
        ) {
209
          this._selectedChoice = getLinkId(configLink)
4✔
210
          this.linkSelected.emit(configLink)
4✔
211
          return configLink
4✔
212
        } else if (id) {
136✔
213
          this._selectedChoice = id
28✔
214
          this.linkSelected.emit(this.linkMap.get(id))
28✔
215
          return this.linkMap.get(id)
28✔
216
        } else {
217
          this.linkSelected.emit(compatibleLinks[0])
108✔
218
          return compatibleLinks[0]
108✔
219
        }
220
      }
221
    })
222
  )
223

224
  styleLinks$ = this.selectedSourceLink$.pipe(
61✔
225
    switchMap((src) => {
226
      if (
70✔
227
        src &&
193✔
228
        src.type === 'service' &&
229
        src.accessServiceProtocol === 'tms'
230
      ) {
231
        return from(
9✔
232
          // WARNING: when using "getGeodataLinksFromTms", make sure to add error handling to prevent the rest of the logic from failing
233
          // this may happen when TMS endpoint is in error
234
          this.dataService.getGeodataLinksFromTms(
235
            src as DatasetServiceDistribution,
236
            false
237
          )
238
        ).pipe(
239
          // We need to check for maplibre-style links because when a TMS service has no styles,
240
          // getGeodataLinksFromTms returns the original TMS link, which isn't a maplibre-style link
241
          map(
242
            (links) =>
243
              links?.filter(
6!
244
                (link) =>
245
                  link.type === 'service' &&
12✔
246
                  link.accessServiceProtocol === 'maplibre-style'
247
              ) || []
248
          ),
249
          catchError((error) => {
250
            this.handleError(error)
2✔
251
            return of(src)
2✔
252
          })
253
        )
254
      }
255
      return of([])
61✔
256
    }),
257
    tap((styles) => {
258
      if (this._styleFromConfig && this._styleFromConfig <= styles.length) {
69!
UNCOV
259
        this.selectedStyleId$.next(this._styleFromConfig)
×
260
      } else {
261
        this.selectedStyleId$.next(0)
69✔
262
      }
263
    }),
264
    shareReplay(1)
265
  )
266

267
  styleDropdownChoices$ = this.styleLinks$.pipe(
61✔
268
    map((links) =>
269
      links.length
73✔
270
        ? links.map((link, index) => ({
12✔
271
            label: getLinkLabel(link),
272
            value: index,
273
          }))
274
        : [
275
            {
276
              label: '\u00A0\u00A0\u00A0\u00A0',
277
              value: 0,
278
            },
279
          ]
280
    )
281
  )
282

283
  selectedLink$ = combineLatest([
61✔
284
    this.selectedSourceLink$,
285
    this.styleLinks$,
286
    this.selectedStyleId$.pipe(distinctUntilChanged()),
287
  ]).pipe(
288
    map(([src, styles, styleIdx]) => (styles.length ? styles[styleIdx] : src)),
96✔
289
    shareReplay(1)
290
  )
291

292
  currentLayers$ = combineLatest([this.selectedLink$, this.excludeWfs$]).pipe(
61✔
293
    switchMap(([link, excludeWfs]) => {
294
      if (!link) {
98✔
295
        return of([])
5✔
296
      }
297
      if (excludeWfs && link.accessServiceProtocol === 'wfs') {
93✔
298
        this.hidePreview = true
2✔
299
        return of([])
2✔
300
      }
301
      this.hidePreview = false
91✔
302
      this.loading = true
91✔
303
      if (link.accessRestricted) {
91✔
304
        this.handleError('dataset.error.restrictedAccess')
1✔
305
        return of([])
1✔
306
      }
307
      return this.getLayerFromLink(link).pipe(
90✔
308
        map((layer) => [layer]),
79✔
309
        catchError((e) => {
310
          this.handleError(e)
3✔
311
          return of([])
3✔
312
        }),
313
        finalize(() => (this.loading = false))
88✔
314
      )
315
    })
316
  )
317

318
  mapContext$: Observable<MapContext> = this.currentLayers$.pipe(
61✔
319
    switchMap((layers) =>
320
      from(createViewFromLayer(layers[0])).pipe(
90✔
321
        catchError(() => of(null)), // could not zoom on the layer: use the record extent
2✔
322
        map((view) => ({
48✔
323
          layers,
324
          view,
325
        })),
326
        tap(() => {
327
          this.resetSelection()
48✔
328
        })
329
      )
330
    ),
331
    startWith({
332
      layers: [],
333
      view: null,
334
    }),
335
    withLatestFrom(this.mdViewFacade.metadata$),
336
    map(([context, metadata]) => {
337
      if (context.view) return context
109✔
338
      const extent = this.mapUtils.getRecordExtent(metadata)
106✔
339
      const view = extent ? { extent } : null
106✔
340
      return {
106✔
341
        ...context,
342
        view,
343
      }
344
    }),
345
    shareReplay(1)
346
  )
347

348
  constructor(
349
    private mdViewFacade: MdViewFacade,
61!
350
    private mapUtils: MapUtilsService,
61!
351
    private dataService: DataService,
61!
352
    private changeRef: ChangeDetectorRef,
61!
353
    private translateService: TranslateService
61✔
354
  ) {}
355

356
  async ngAfterViewInit() {
357
    const map = await this.mapContainer.openlayersMap
61✔
358
    prioritizePageScroll(map.getInteractions())
61✔
359
  }
360

361
  onMapFeatureSelect(features: Feature[]): void {
362
    this.resetSelection()
3✔
363
    this.selection = features?.length > 0 && features[0]
3!
364
    if (this.selection) {
3✔
365
      // FIXME: restore styling of selected feature
366
      // this.selection.setStyle(this.selectionStyle)
367
    }
368
    this.changeRef.detectChanges()
3✔
369
  }
370

371
  onSourceLoadError(error: SourceLoadErrorEvent) {
372
    if (error.httpStatus === 403 || error.httpStatus === 401) {
3✔
373
      this.error = this.translateService.instant(`dataset.error.forbidden`)
2✔
374
    } else {
375
      this.error = this.translateService.instant(`dataset.error.http`, {
1✔
376
        info: error.httpStatus,
377
      })
378
    }
379
  }
380

381
  resetSelection(): void {
382
    if (this.selection) {
52✔
383
      // FIXME: restore styling of selected feature
384
      // this.selection.setStyle(null)
385
    }
386
    this.selection = null
52✔
387
  }
388

389
  getLayerFromLink(link: DatasetOnlineResource): Observable<MapContextLayer> {
390
    if (link.type === 'service' && link.accessServiceProtocol === 'wms') {
90✔
391
      return of({
57✔
392
        url: link.url.toString(),
393
        type: 'wms',
394
        name: link.name,
395
      })
396
    } else if (
33✔
397
      link.type === 'service' &&
56✔
398
      link.accessServiceProtocol === 'tms'
399
    ) {
400
      // FIXME: here we're assuming that the TMS serves vector tiles only; should be checked with ogc-client first
401
      return of({
9✔
402
        url: link.url
403
          .toString()
404
          .replace(/\/?$/, `/${link.name}/{z}/{x}/{y}.pbf`),
405
        type: 'xyz',
406
        tileFormat: 'application/vnd.mapbox-vector-tile',
407
        name: link.name,
408
      })
409
    } else if (
24✔
410
      link.type === 'service' &&
38✔
411
      link.accessServiceProtocol === 'maplibre-style'
412
    ) {
413
      return of({
6✔
414
        type: 'maplibre-style',
415
        name: link.name,
416
        styleUrl: link.url.toString(),
417
      })
418
    } else if (
18✔
419
      link.type === 'service' &&
26✔
420
      link.accessServiceProtocol === 'wmts'
421
    ) {
422
      return of({
1✔
423
        url: link.url.toString(),
424
        type: 'wmts',
425
        name: link.name,
426
      })
427
    } else if (
17✔
428
      (link.type === 'service' &&
39✔
429
        (link.accessServiceProtocol === 'wfs' ||
430
          link.accessServiceProtocol === 'esriRest' ||
431
          link.accessServiceProtocol === 'ogcFeatures')) ||
432
      link.type === 'download'
433
    ) {
434
      const cacheActive = true // TODO implement whether should be true or false
17✔
435
      return this.dataService.readAsGeoJson(link, cacheActive).pipe(
17✔
436
        map((data) => ({
6✔
437
          type: 'geojson',
438
          data,
439
        }))
440
      )
441
    }
UNCOV
442
    return throwError(() => 'protocol not supported')
×
443
  }
444
  handleError(error: FetchError | Error | string) {
445
    if (error instanceof FetchError) {
9✔
446
      this.error = this.translateService.instant(
1✔
447
        `dataset.error.${error.type}`,
448
        {
449
          info: error.info,
450
        }
451
      )
452
      console.warn(error.message)
1✔
453
    } else if (error instanceof Error) {
8✔
454
      this.error = this.translateService.instant(error.message)
5✔
455
      console.warn(error.stack || error)
5!
456
    } else {
457
      this.error = this.translateService.instant(error)
3✔
458
      console.warn(error)
3✔
459
    }
460
    this.loading = false
9✔
461
    this.changeRef.detectChanges()
9✔
462
  }
463
}
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