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

geonetwork / geonetwork-ui / 16470362655

23 Jul 2025 12:15PM UTC coverage: 83.837% (-0.3%) from 84.102%
16470362655

push

github

web-flow
[Datahub] : Read preview configuration (#1278)

[Datahub] : Read preview configuration

3522 of 4722 branches covered (74.59%)

Branch coverage included in aggregate %.

103 of 108 new or added lines in 7 files covered. (95.37%)

1 existing line in 1 file now uncovered.

10068 of 11488 relevant lines covered (87.64%)

272.32 hits per line

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

94.51
/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 { NgIconComponent, provideIcons } from '@ng-icons/core'
1✔
53
import { matClose } from '@ng-icons/material-icons/baseline'
1✔
54
import { CommonModule } from '@angular/common'
1✔
55
import {
1✔
56
  ButtonComponent,
57
  DropdownSelectorComponent,
58
} from '@geonetwork-ui/ui/inputs'
59
import {
1✔
60
  TranslateDirective,
61
  TranslatePipe,
62
  TranslateService,
63
} from '@ngx-translate/core'
64
import { ExternalViewerButtonComponent } from '../external-viewer-button/external-viewer-button.component'
1✔
65
import {
1✔
66
  LoadingMaskComponent,
67
  PopupAlertComponent,
68
} from '@geonetwork-ui/ui/widgets'
69
import { marker } from '@biesbjerg/ngx-translate-extract-marker'
1✔
70
import { FetchError } from '@geonetwork-ui/data-fetcher'
1✔
71

72
marker('map.dropdown.placeholder')
1✔
73
marker('wfs.feature.limit')
1✔
74
marker('dataset.error.restrictedAccess')
1✔
75
marker('map.select.style')
1✔
76

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

107
  linkMap: Map<string, DatasetOnlineResource> = new Map()
61✔
108
  @Input() set selectedView(value: string) {
109
    this.selectedView$.next(value)
1✔
110
  }
111
  @Input() set datavizConfig(value: any) {
112
    if (value.view === 'map') {
2✔
113
      this.selectedView$.next(value.view)
2✔
114
      if (value.styleTMSIndex) {
2✔
115
        this._styleFromConfig = value.styleTMSIndex
1✔
116
      }
117
      if (value.source) {
2✔
118
        this.linkFromConfig$.next(value.source)
2✔
119
      }
120
    }
121
  }
122
  @Input() displaySource = true
61✔
123
  @Output() linkSelected = new EventEmitter<DatasetOnlineResource>()
61✔
124
  @Output() styleSelected = new EventEmitter<number>()
61✔
125
  @ViewChild('mapContainer') mapContainer: MapContainerComponent
126

127
  excludeWfs$ = new BehaviorSubject(false)
61✔
128
  hidePreview = false
61✔
129
  selection: Feature
130
  showLegend = true
61✔
131
  legendExists = false
61✔
132
  loading = false
61✔
133
  error = null
61✔
134

135
  selectLinkToDisplay(id: string) {
136
    this.selectedLinkId$.next(id)
15✔
137
  }
138

139
  selectStyleToDisplay(i: number) {
140
    this.selectedStyleId$.next(i)
2✔
141
    this.styleSelected.emit(i)
2✔
142
  }
143

144
  toggleLegend() {
145
    this.showLegend = !this.showLegend
×
146
  }
147
  onLegendStatusChange(v: boolean) {
148
    this.legendExists = v
1✔
149
  }
150

151
  compatibleMapLinks$ = combineLatest([
61✔
152
    this.mdViewFacade.mapApiLinks$,
153
    this.mdViewFacade.geoDataLinksWithGeometry$,
154
  ]).pipe(
155
    map(([mapApiLinks, geoDataLinksWithGeometry]) => [
51✔
156
      ...mapApiLinks,
157
      ...geoDataLinksWithGeometry,
158
    ]),
159
    shareReplay(1)
160
  )
161

162
  dropdownChoices$ = this.compatibleMapLinks$.pipe(
61✔
163
    map((links) => {
164
      this.linkMap.clear()
51✔
165
      links.forEach((link: DatasetOnlineResource) =>
51✔
166
        this.linkMap.set(getLinkId(link), link)
90✔
167
      )
168
      return links.length
51✔
169
        ? links.map((link) => ({
90✔
170
            label: getLinkLabel(link),
171
            value: getLinkId(link),
172
          }))
173
        : [{ label: 'map.dropdown.placeholder', value: '' }]
174
    })
175
  )
176

177
  selectedView$ = new BehaviorSubject(null)
61✔
178
  selectedLinkId$ = new BehaviorSubject(null)
61✔
179
  selectedStyleId$ = new BehaviorSubject(null)
61✔
180

181
  selectedSourceLink$ = combineLatest([
61✔
182
    this.compatibleMapLinks$,
183
    this.linkFromConfig$,
184
    this.selectedLinkId$.pipe(distinctUntilChanged()),
185
    this.selectedView$,
186
  ]).pipe(
187
    tap(() => {
188
      this.error = null
140✔
189
    }),
190
    map(([compatibleLinks, configLink, id, view]) => {
191
      if (view === 'map') {
140✔
192
        if (
140✔
193
          configLink &&
148✔
194
          !id &&
195
          compatibleLinks.some(
196
            (link) => getLinkId(link) === getLinkId(configLink)
6✔
197
          )
198
        ) {
199
          this._selectedChoice = getLinkId(configLink)
4✔
200
          this.linkSelected.emit(configLink)
4✔
201
          return configLink
4✔
202
        } else if (id) {
136✔
203
          this._selectedChoice = id
28✔
204
          this.linkSelected.emit(this.linkMap.get(id))
28✔
205
          return this.linkMap.get(id)
28✔
206
        } else {
207
          this.linkSelected.emit(compatibleLinks[0])
108✔
208
          return compatibleLinks[0]
108✔
209
        }
210
      }
211
    })
212
  )
213

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

257
  styleDropdownChoices$ = this.styleLinks$.pipe(
61✔
258
    map((links) =>
259
      links.length
73✔
260
        ? links.map((link, index) => ({
12✔
261
            label: getLinkLabel(link),
262
            value: index,
263
          }))
264
        : [
265
            {
266
              label: '\u00A0\u00A0\u00A0\u00A0',
267
              value: 0,
268
            },
269
          ]
270
    )
271
  )
272

273
  selectedLink$ = combineLatest([
61✔
274
    this.selectedSourceLink$,
275
    this.styleLinks$,
276
    this.selectedStyleId$.pipe(distinctUntilChanged()),
277
  ]).pipe(
278
    map(([src, styles, styleIdx]) => (styles.length ? styles[styleIdx] : src)),
96✔
279
    shareReplay(1)
280
  )
281

282
  currentLayers$ = combineLatest([this.selectedLink$, this.excludeWfs$]).pipe(
61✔
283
    switchMap(([link, excludeWfs]) => {
284
      if (!link) {
98✔
285
        return of([])
5✔
286
      }
287
      if (excludeWfs && link.accessServiceProtocol === 'wfs') {
93✔
288
        this.hidePreview = true
2✔
289
        return of([])
2✔
290
      }
291
      this.hidePreview = false
91✔
292
      this.loading = true
91✔
293
      if (link.accessRestricted) {
91✔
294
        this.handleError('dataset.error.restrictedAccess')
1✔
295
        return of([])
1✔
296
      }
297
      return this.getLayerFromLink(link).pipe(
90✔
298
        map((layer) => [layer]),
79✔
299
        catchError((e) => {
300
          this.handleError(e)
3✔
301
          return of([])
3✔
302
        }),
303
        finalize(() => (this.loading = false))
88✔
304
      )
305
    })
306
  )
307

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

338
  constructor(
339
    private mdViewFacade: MdViewFacade,
61!
340
    private mapUtils: MapUtilsService,
61!
341
    private dataService: DataService,
61!
342
    private changeRef: ChangeDetectorRef,
61!
343
    private translateService: TranslateService
61✔
344
  ) {}
345

346
  async ngAfterViewInit() {
347
    const map = await this.mapContainer.openlayersMap
61✔
348
    prioritizePageScroll(map.getInteractions())
61✔
349
  }
350

351
  onMapFeatureSelect(features: Feature[]): void {
352
    this.resetSelection()
3✔
353
    this.selection = features?.length > 0 && features[0]
3!
354
    if (this.selection) {
3✔
355
      // FIXME: restore styling of selected feature
356
      // this.selection.setStyle(this.selectionStyle)
357
    }
358
    this.changeRef.detectChanges()
3✔
359
  }
360

361
  onSourceLoadError(error: SourceLoadErrorEvent) {
362
    if (error.httpStatus === 403 || error.httpStatus === 401) {
3✔
363
      this.error = this.translateService.instant(`dataset.error.forbidden`)
2✔
364
    } else {
365
      this.error = this.translateService.instant(`dataset.error.http`, {
1✔
366
        info: error.httpStatus,
367
      })
368
    }
369
  }
370

371
  resetSelection(): void {
372
    if (this.selection) {
52✔
373
      // FIXME: restore styling of selected feature
374
      // this.selection.setStyle(null)
375
    }
376
    this.selection = null
52✔
377
  }
378

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