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

geonetwork / geonetwork-ui / 15254317382

26 May 2025 12:45PM UTC coverage: 84.052% (-0.4%) from 84.431%
15254317382

Pull #1254

github

web-flow
Merge 1126dff28 into d1dbf336f
Pull Request #1254: [Datahub] TMS source – split source and style selector

702 of 946 branches covered (74.21%)

Branch coverage included in aggregate %.

35 of 36 new or added lines in 1 file covered. (97.22%)

1833 of 2070 relevant lines covered (88.55%)

7.58 hits per line

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

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

65
marker('map.dropdown.placeholder')
1✔
66
marker('wfs.feature.limit')
1✔
67
marker('dataset.error.restrictedAccess')
1✔
68
marker('map.select.style')
1✔
69

70
@Component({
71
  selector: 'gn-ui-map-view',
72
  templateUrl: './map-view.component.html',
73
  styleUrls: ['./map-view.component.css'],
74
  changeDetection: ChangeDetectionStrategy.OnPush,
75
  standalone: true,
76
  imports: [
77
    CommonModule,
78
    DropdownSelectorComponent,
79
    MapContainerComponent,
80
    FeatureDetailComponent,
81
    PopupAlertComponent,
82
    TranslateModule,
83
    LoadingMaskComponent,
84
    NgIconComponent,
85
    ExternalViewerButtonComponent,
86
    ButtonComponent,
87
    MapLegendComponent,
88
  ],
89
  viewProviders: [provideIcons({ matClose })],
90
})
91
export class MapViewComponent implements AfterViewInit {
1✔
92
  @Input() set exceedsLimit(value: boolean) {
93
    this.excludeWfs$.next(value)
×
94
  }
95
  @Input() displaySource = true
55✔
96
  @ViewChild('mapContainer') mapContainer: MapContainerComponent
97

98
  excludeWfs$ = new BehaviorSubject(false)
55✔
99
  hidePreview = false
55✔
100
  selection: Feature
101
  showLegend = true
55✔
102
  legendExists = false
55✔
103
  loading = false
55✔
104
  error = null
55✔
105

106
  constructor(
107
    private mdViewFacade: MdViewFacade,
55!
108
    private mapUtils: MapUtilsService,
55!
109
    private dataService: DataService,
55!
110
    private changeRef: ChangeDetectorRef,
55!
111
    private translateService: TranslateService
55✔
112
  ) {}
113

114
  compatibleMapLinks$ = combineLatest([
55✔
115
    this.mdViewFacade.mapApiLinks$,
116
    this.mdViewFacade.geoDataLinksWithGeometry$,
117
  ]).pipe(
118
    switchMap(async ([mapApiLinks, geoDataLinksWithGeometry]) => {
47✔
119
      // looking for TMS links to process
120
      const processedMapApiLinks = await Promise.all(
47✔
121
        mapApiLinks.map((link) =>
122
          link.type === 'service' && link.accessServiceProtocol === 'tms'
51✔
123
            ? this.dataService.getGeodataLinksFromTms(
124
                link as DatasetServiceDistribution,
125
                true
126
              )
127
            : link
128
        )
129
      )
130
      return [...processedMapApiLinks.flat(), ...geoDataLinksWithGeometry]
47✔
131
    }),
132
    shareReplay(1)
133
  )
134

135
  sourceLinks$ = this.compatibleMapLinks$.pipe(
55✔
136
    map((links) => {
137
      const nonStyle = links.filter(
41✔
138
        (l) =>
139
          !(
78✔
140
            l.type === 'service' && l.accessServiceProtocol === 'maplibre-style'
147✔
141
          )
142
      )
143
      return nonStyle.length ? nonStyle : links
41✔
144
    }),
145
    shareReplay(1)
146
  )
147

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

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

162
  selectedSourceLink$ = combineLatest([
55✔
163
    this.sourceLinks$,
164
    this.selectedLinkIndex$.pipe(distinctUntilChanged()),
165
  ]).pipe(
166
    map(([links, idx]) => links[idx]),
47✔
167
    shareReplay(1)
168
  )
169

170
  styleLinks$ = combineLatest([
55✔
171
    this.compatibleMapLinks$,
172
    this.selectedSourceLink$,
173
  ]).pipe(
174
    map(([all, src]) => {
175
      if (
47✔
176
        src &&
129✔
177
        src.type === 'service' &&
178
        src.accessServiceProtocol === 'tms'
179
      ) {
180
        const layerId = src.url.pathname.split('/').pop() || ''
4!
181
        return all.filter(
4✔
182
          (l) =>
183
            l.type === 'service' &&
12✔
184
            l.accessServiceProtocol === 'maplibre-style' &&
185
            l.url.pathname.includes(layerId)
186
        )
187
      }
188
      return []
43✔
189
    }),
190
    tap(() => this.selectedStyleIndex$.next(0)),
47✔
191
    shareReplay(1)
192
  )
193

194
  styleDropdownChoices$ = this.styleLinks$.pipe(
55✔
195
    map((links) =>
196
      links.length
51✔
197
        ? links.map((l, i) => ({ label: getLinkLabel(l), value: i }))
12✔
198
        : [
199
            {
200
              label: '\u00A0\u00A0\u00A0\u00A0',
201
              value: 0,
202
            },
203
          ]
204
    )
205
  )
206

207
  selectedLink$ = combineLatest([
55✔
208
    this.selectedSourceLink$,
209
    this.styleLinks$,
210
    this.selectedStyleIndex$.pipe(distinctUntilChanged()),
211
  ]).pipe(
212
    map(([src, styles, styleIdx]) => (styles.length ? styles[styleIdx] : src)),
55✔
213
    shareReplay(1)
214
  )
215

216
  currentLayers$ = combineLatest([this.selectedLink$, this.excludeWfs$]).pipe(
55✔
217
    switchMap(([link, excludeWfs]) => {
218
      if (!link) {
57✔
219
        return of([])
4✔
220
      }
221
      if (excludeWfs && link.accessServiceProtocol === 'wfs') {
53✔
222
        this.hidePreview = true
4✔
223
        return of([])
4✔
224
      }
225
      this.hidePreview = false
49✔
226
      this.loading = true
49✔
227
      this.error = null
49✔
228
      if (link.accessRestricted) {
49✔
229
        this.handleError('dataset.error.restrictedAccess')
1✔
230
        return of([])
1✔
231
      }
232
      return this.getLayerFromLink(link).pipe(
48✔
233
        map((layer) => [layer]),
43✔
234
        catchError((e) => {
235
          this.handleError(e)
3✔
236
          return of([])
3✔
237
        }),
238
        finalize(() => (this.loading = false))
46✔
239
      )
240
    })
241
  )
242

243
  mapContext$: Observable<MapContext> = this.currentLayers$.pipe(
55✔
244
    switchMap((layers) =>
245
      from(createViewFromLayer(layers[0])).pipe(
55✔
246
        catchError(() => of(null)), // could not zoom on the layer: use the record extent
2✔
247
        map((view) => ({
42✔
248
          layers,
249
          view,
250
        })),
251
        tap(() => {
252
          this.resetSelection()
42✔
253
        })
254
      )
255
    ),
256
    startWith({
257
      layers: [],
258
      view: null,
259
    }),
260
    withLatestFrom(this.mdViewFacade.metadata$),
261
    map(([context, metadata]) => {
262
      if (context.view) return context
97✔
263
      const extent = this.mapUtils.getRecordExtent(metadata)
94✔
264
      const view = extent ? { extent } : null
94✔
265
      return {
94✔
266
        ...context,
267
        view,
268
      }
269
    }),
270
    shareReplay(1)
271
  )
272

273
  async ngAfterViewInit() {
274
    const map = await this.mapContainer.openlayersMap
55✔
275
    prioritizePageScroll(map.getInteractions())
55✔
276
  }
277

278
  selectLinkToDisplay(i: number) {
279
    this.selectedLinkIndex$.next(i)
15✔
280
  }
281
  selectStyleToDisplay(i: number) {
282
    this.selectedStyleIndex$.next(i)
1✔
283
  }
284

285
  toggleLegend() {
NEW
286
    this.showLegend = !this.showLegend
×
287
  }
288
  onLegendStatusChange(v: boolean) {
289
    this.legendExists = v
1✔
290
  }
291
  onMapFeatureSelect(features: Feature[]): void {
292
    this.resetSelection()
3✔
293
    this.selection = features?.length > 0 && features[0]
3!
294
    if (this.selection) {
3✔
295
      // FIXME: restore styling of selected feature
296
      // this.selection.setStyle(this.selectionStyle)
297
    }
298
    this.changeRef.detectChanges()
3✔
299
  }
300

301
  onSourceLoadError(error: SourceLoadErrorEvent) {
302
    if (error.httpStatus === 403 || error.httpStatus === 401) {
3✔
303
      this.error = this.translateService.instant(`dataset.error.forbidden`)
2✔
304
    } else {
305
      this.error = this.translateService.instant(`dataset.error.http`, {
1✔
306
        info: error.httpStatus,
307
      })
308
    }
309
  }
310

311
  resetSelection(): void {
312
    if (this.selection) {
46✔
313
      // FIXME: restore styling of selected feature
314
      // this.selection.setStyle(null)
315
    }
316
    this.selection = null
46✔
317
  }
318

319
  getLayerFromLink(link: DatasetOnlineResource): Observable<MapContextLayer> {
320
    if (link.type === 'service' && link.accessServiceProtocol === 'wms') {
48✔
321
      return of({
27✔
322
        url: link.url.toString(),
323
        type: 'wms',
324
        name: link.name,
325
      })
326
    } else if (
21✔
327
      link.type === 'service' &&
38✔
328
      link.accessServiceProtocol === 'tms'
329
    ) {
330
      // FIXME: here we're assuming that the TMS serves vector tiles only; should be checked with ogc-client first
331
      return of({
2✔
332
        url: link.url.toString().replace(/\/?$/, '/{z}/{x}/{y}.pbf'),
333
        type: 'xyz',
334
        tileFormat: 'application/vnd.mapbox-vector-tile',
335
        name: link.name,
336
      })
337
    } else if (
19✔
338
      link.type === 'service' &&
34✔
339
      link.accessServiceProtocol === 'maplibre-style'
340
    ) {
341
      return of({
7✔
342
        type: 'maplibre-style',
343
        name: link.name,
344
        styleUrl: link.url.toString(),
345
      })
346
    } else if (
12✔
347
      link.type === 'service' &&
20✔
348
      link.accessServiceProtocol === 'wmts'
349
    ) {
350
      return of({
1✔
351
        url: link.url.toString(),
352
        type: 'wmts',
353
        name: link.name,
354
      })
355
    } else if (
11✔
356
      (link.type === 'service' &&
27✔
357
        (link.accessServiceProtocol === 'wfs' ||
358
          link.accessServiceProtocol === 'esriRest' ||
359
          link.accessServiceProtocol === 'ogcFeatures')) ||
360
      link.type === 'download'
361
    ) {
362
      const cacheActive = true // TODO implement whether should be true or false
11✔
363
      return this.dataService.readAsGeoJson(link, cacheActive).pipe(
11✔
364
        map((data) => ({
6✔
365
          type: 'geojson',
366
          data,
367
        }))
368
      )
369
    }
370
    return throwError(() => 'protocol not supported')
×
371
  }
372
  handleError(error: FetchError | Error | string) {
373
    if (error instanceof FetchError) {
7✔
374
      this.error = this.translateService.instant(
1✔
375
        `dataset.error.${error.type}`,
376
        {
377
          info: error.info,
378
        }
379
      )
380
      console.warn(error.message)
1✔
381
    } else if (error instanceof Error) {
6✔
382
      this.error = this.translateService.instant(error.message)
4✔
383
      console.warn(error.stack || error)
4!
384
    } else {
385
      this.error = this.translateService.instant(error)
2✔
386
      console.warn(error)
2✔
387
    }
388
    this.loading = false
7✔
389
    this.changeRef.detectChanges()
7✔
390
  }
391
}
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