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

geonetwork / geonetwork-ui / 16963588182

14 Aug 2025 11:13AM UTC coverage: 84.253% (+0.005%) from 84.248%
16963588182

push

github

web-flow
Merge pull request #1325 from geonetwork/backport/2.6.x/quality-system-various-fixes

Backport/2.6.x/quality system various fixes

3706 of 4910 branches covered (75.48%)

Branch coverage included in aggregate %.

10505 of 11957 relevant lines covered (87.86%)

362.77 hits per line

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

92.56
/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 { 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
  @Input() set selectedView(value: string) {
104
    if (value === 'map') {
×
105
      this.selectedLink$.pipe(take(1)).subscribe((link) => {
×
106
        this.linkSelected.emit(link)
×
107
      })
108
    }
109
  }
110
  @Input() displaySource = true
57✔
111
  @Output() linkSelected = new EventEmitter<DatasetOnlineResource>()
57✔
112
  @ViewChild('mapContainer') mapContainer: MapContainerComponent
113

114
  excludeWfs$ = new BehaviorSubject(false)
57✔
115
  hidePreview = false
57✔
116
  selection: Feature
117
  showLegend = true
57✔
118
  legendExists = false
57✔
119
  loading = false
57✔
120
  error = null
57✔
121

122
  selectLinkToDisplay(i: number) {
123
    this.selectedLinkIndex$.next(i)
15✔
124
  }
125

126
  selectStyleToDisplay(i: number) {
127
    this.selectedStyleIndex$.next(i)
1✔
128
  }
129

130
  toggleLegend() {
131
    this.showLegend = !this.showLegend
×
132
  }
133
  onLegendStatusChange(v: boolean) {
134
    this.legendExists = v
1✔
135
  }
136

137
  compatibleMapLinks$ = combineLatest([
57✔
138
    this.mdViewFacade.mapApiLinks$,
139
    this.mdViewFacade.geoDataLinksWithGeometry$,
140
  ]).pipe(
141
    map(([mapApiLinks, geoDataLinksWithGeometry]) => [
49✔
142
      ...mapApiLinks,
143
      ...geoDataLinksWithGeometry,
144
    ]),
145
    shareReplay(1)
146
  )
147

148
  dropdownChoices$ = this.compatibleMapLinks$.pipe(
57✔
149
    map((links) =>
150
      links.length
49✔
151
        ? links.map((link, index) => ({
85✔
152
            label: getLinkLabel(link),
153
            value: index,
154
          }))
155
        : [{ label: 'map.dropdown.placeholder', value: 0 }]
156
    )
157
  )
158

159
  selectedLinkIndex$ = new BehaviorSubject(0)
57✔
160
  private selectedStyleIndex$ = new BehaviorSubject(0)
57✔
161

162
  selectedSourceLink$ = combineLatest([
57✔
163
    this.compatibleMapLinks$,
164
    this.selectedLinkIndex$.pipe(distinctUntilChanged()),
165
  ]).pipe(
166
    tap(() => {
167
      this.error = null
128✔
168
    }),
169
    map(([links, index]) => {
170
      this.linkSelected.emit(links[index])
128✔
171
      return links[index]
128✔
172
    })
173
  )
174

175
  styleLinks$ = this.selectedSourceLink$.pipe(
57✔
176
    switchMap((src) => {
177
      if (
64✔
178
        src &&
177✔
179
        src.type === 'service' &&
180
        src.accessServiceProtocol === 'tms'
181
      ) {
182
        return from(
6✔
183
          // WARNING: when using "getGeodataLinksFromTms", make sure to add error handling to prevent the rest of the logic from failing
184
          // this may happen when TMS endpoint is in error
185
          this.dataService.getGeodataLinksFromTms(
186
            src as DatasetServiceDistribution,
187
            false
188
          )
189
        ).pipe(
190
          // We need to check for maplibre-style links because when a TMS service has no styles,
191
          // getGeodataLinksFromTms returns the original TMS link, which isn't a maplibre-style link
192
          map(
193
            (links) =>
194
              links?.filter(
4!
195
                (link) =>
196
                  link.type === 'service' &&
10✔
197
                  link.accessServiceProtocol === 'maplibre-style'
198
              ) || []
199
          ),
200
          catchError((error) => {
201
            this.handleError(error)
2✔
202
            return of(src)
2✔
203
          })
204
        )
205
      }
206
      return of([])
58✔
207
    }),
208
    tap(() => this.selectedStyleIndex$.next(0)),
64✔
209
    shareReplay(1)
210
  )
211

212
  styleDropdownChoices$ = this.styleLinks$.pipe(
57✔
213
    map((links) =>
214
      links.length
68✔
215
        ? links.map((link, index) => ({
12✔
216
            label: getLinkLabel(link),
217
            value: index,
218
          }))
219
        : [
220
            {
221
              label: '\u00A0\u00A0\u00A0\u00A0',
222
              value: 0,
223
            },
224
          ]
225
    )
226
  )
227

228
  selectedLink$ = combineLatest([
57✔
229
    this.selectedSourceLink$,
230
    this.styleLinks$,
231
    this.selectedStyleIndex$.pipe(distinctUntilChanged()),
232
  ]).pipe(
233
    map(([src, styles, styleIdx]) => (styles.length ? styles[styleIdx] : src)),
87✔
234
    shareReplay(1)
235
  )
236

237
  currentLayers$ = combineLatest([this.selectedLink$, this.excludeWfs$]).pipe(
57✔
238
    switchMap(([link, excludeWfs]) => {
239
      if (!link) {
89✔
240
        return of([])
4✔
241
      }
242
      if (excludeWfs && link.accessServiceProtocol === 'wfs') {
85✔
243
        this.hidePreview = true
4✔
244
        return of([])
4✔
245
      }
246
      this.hidePreview = false
81✔
247
      this.loading = true
81✔
248
      if (link.accessRestricted) {
81✔
249
        this.handleError('dataset.error.restrictedAccess')
1✔
250
        return of([])
1✔
251
      }
252
      return this.getLayerFromLink(link).pipe(
80✔
253
        map((layer) => [layer]),
69✔
254
        catchError((e) => {
255
          this.handleError(e)
3✔
256
          return of([])
3✔
257
        }),
258
        finalize(() => (this.loading = false))
78✔
259
      )
260
    })
261
  )
262

263
  mapContext$: Observable<MapContext> = this.currentLayers$.pipe(
57✔
264
    switchMap((layers) =>
265
      from(createViewFromLayer(layers[0])).pipe(
81✔
266
        catchError(() => of(null)), // could not zoom on the layer: use the record extent
2✔
267
        map((view) => ({
44✔
268
          layers,
269
          view,
270
        })),
271
        tap(() => {
272
          this.resetSelection()
44✔
273
        })
274
      )
275
    ),
276
    startWith({
277
      layers: [],
278
      view: null,
279
    }),
280
    withLatestFrom(this.mdViewFacade.metadata$),
281
    map(([context, metadata]) => {
282
      if (context.view) return context
101✔
283
      const extent = this.mapUtils.getRecordExtent(metadata)
98✔
284
      const view = extent ? { extent } : null
98✔
285
      return {
98✔
286
        ...context,
287
        view,
288
      }
289
    }),
290
    shareReplay(1)
291
  )
292

293
  constructor(
294
    private mdViewFacade: MdViewFacade,
57!
295
    private mapUtils: MapUtilsService,
57!
296
    private dataService: DataService,
57!
297
    private changeRef: ChangeDetectorRef,
57!
298
    private translateService: TranslateService
57✔
299
  ) {}
300

301
  async ngAfterViewInit() {
302
    const map = await this.mapContainer.openlayersMap
57✔
303
    prioritizePageScroll(map.getInteractions())
57✔
304
  }
305

306
  onMapFeatureSelect(features: Feature[]): void {
307
    this.resetSelection()
3✔
308
    this.selection = features?.length > 0 && features[0]
3!
309
    if (this.selection) {
3✔
310
      // FIXME: restore styling of selected feature
311
      // this.selection.setStyle(this.selectionStyle)
312
    }
313
    this.changeRef.detectChanges()
3✔
314
  }
315

316
  onSourceLoadError(error: SourceLoadErrorEvent) {
317
    if (error.httpStatus === 403 || error.httpStatus === 401) {
3✔
318
      this.error = this.translateService.instant(`dataset.error.forbidden`)
2✔
319
    } else {
320
      this.error = this.translateService.instant(`dataset.error.http`, {
1✔
321
        info: error.httpStatus,
322
      })
323
    }
324
  }
325

326
  resetSelection(): void {
327
    if (this.selection) {
48✔
328
      // FIXME: restore styling of selected feature
329
      // this.selection.setStyle(null)
330
    }
331
    this.selection = null
48✔
332
  }
333

334
  getLayerFromLink(link: DatasetOnlineResource): Observable<MapContextLayer> {
335
    if (link.type === 'service' && link.accessServiceProtocol === 'wms') {
80✔
336
      return of({
51✔
337
        url: link.url.toString(),
338
        type: 'wms',
339
        name: link.name,
340
      })
341
    } else if (
29✔
342
      link.type === 'service' &&
48✔
343
      link.accessServiceProtocol === 'tms'
344
    ) {
345
      // FIXME: here we're assuming that the TMS serves vector tiles only; should be checked with ogc-client first
346
      return of({
5✔
347
        url: link.url
348
          .toString()
349
          .replace(/\/?$/, `/${link.name}/{z}/{x}/{y}.pbf`),
350
        type: 'xyz',
351
        tileFormat: 'application/vnd.mapbox-vector-tile',
352
        name: link.name,
353
      })
354
    } else if (
24✔
355
      link.type === 'service' &&
38✔
356
      link.accessServiceProtocol === 'maplibre-style'
357
    ) {
358
      return of({
6✔
359
        type: 'maplibre-style',
360
        name: link.name,
361
        styleUrl: link.url.toString(),
362
      })
363
    } else if (
18✔
364
      link.type === 'service' &&
26✔
365
      link.accessServiceProtocol === 'wmts'
366
    ) {
367
      return of({
1✔
368
        url: link.url.toString(),
369
        type: 'wmts',
370
        name: link.name,
371
      })
372
    } else if (
17✔
373
      (link.type === 'service' &&
39✔
374
        (link.accessServiceProtocol === 'wfs' ||
375
          link.accessServiceProtocol === 'esriRest' ||
376
          link.accessServiceProtocol === 'ogcFeatures')) ||
377
      link.type === 'download'
378
    ) {
379
      const cacheActive = true // TODO implement whether should be true or false
17✔
380
      return this.dataService.readAsGeoJson(link, cacheActive).pipe(
17✔
381
        map((data) => ({
6✔
382
          type: 'geojson',
383
          data,
384
        }))
385
      )
386
    }
387
    return throwError(() => 'protocol not supported')
×
388
  }
389
  handleError(error: FetchError | Error | string) {
390
    if (error instanceof FetchError) {
9✔
391
      this.error = this.translateService.instant(
1✔
392
        `dataset.error.${error.type}`,
393
        {
394
          info: error.info,
395
        }
396
      )
397
      console.warn(error.message)
1✔
398
    } else if (error instanceof Error) {
8✔
399
      this.error = this.translateService.instant(error.message)
5✔
400
      console.warn(error.stack || error)
5!
401
    } else {
402
      this.error = this.translateService.instant(error)
3✔
403
      console.warn(error)
3✔
404
    }
405
    this.loading = false
9✔
406
    this.changeRef.detectChanges()
9✔
407
  }
408
}
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