import { Component, OnInit, OnDestroy, Input, ViewChild, NgZone, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef } from '@angular/core' import { ActivatedRoute, Params, Router } from '@angular/router' import { Observable } from 'rxjs' import { map } from 'rxjs/operators' import { WebSocketSubject } from 'rxjs/webSocket' import { MatSnackBar } from '@angular/material/snack-bar' import { Map, MapMouseEvent, LayerSpecification, GeoJSONSourceSpecification, FitBoundsOptions, ExpressionSpecification, Source, GeoJSONSource } from 'maplibre-gl' import Point from '@mapbox/point-geometry' import * as bbox from '@turf/bbox' import { GeoJsonService, LiveGeoJsonService, MapboxDataAndStyle } from '../../_services/geojson.service' import { ConfigService } from '../../config.service' import { MapControlService } from '../map-control.service' import { LayerNode } from '../models' import { InfoDataService, Feature, TaggedLayer, FeatureWithField, TaggedFeature } from '../../info/info-data.service' import { Store, MapDataService, BaseStyle } from '../map-data.service' export class LayerWithMetaData { constructor( public layer: LayerSpecification, public highlightedLayer: LayerSpecification, public layerNode: LayerNode, ) {} } let normalize = (s: string) => s ? s.trim().toLowerCase() : '' @Component({ selector: 'gisaf-mapbox', templateUrl: 'gisaf-mapbox.component.html', styleUrls: [ 'gisaf-mapbox.component.css' ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ GeoJsonService, LiveGeoJsonService, ], }) export class GisafMapboxComponent implements OnInit, OnDestroy { @ViewChild('mglMap', { static: true }) mglMap: Map @ViewChild('featureInfo', { static: true }) featureInfo: ElementRef @ViewChild('mouseLngLat', { static: true }) mouseLngLat: ElementRef @ViewChild('customAttribution', { static: true }) customAttributionElement: ElementRef // XXX: temporary, for keeping map-controls.component.ts happy map: Map zoom: number = this.configService.conf.value.map['zoom'] pitch: number = this.configService.conf.value.map['pitch'] lat: number = this.configService.conf.value.map['lat'] lng: number = this.configService.conf.value.map['lng'] bearing: number = this.configService.conf.value.map['bearing'] globalAttribution: string = this.configService.conf.value.map['attribution'] baseStyle: BaseStyle = new BaseStyle('None') protected _baseStyleName: string = this.configService.conf.value.map['style'] protected _bottom: number = 0 protected canvas: HTMLElement protected selStart: Point protected selAppend: boolean protected selBox: HTMLDivElement geolocateTrackUserLocation = true geolocateShowUserLocation = true geolocatePositionOptions = { "enableHighAccuracy": true } geolocateFitBoundsOptions: FitBoundsOptions = { "maxZoom": 12, } popupOnFeature: string | null | number layerDefs: object = {} layers: object = {} highlightedLayers: object = {} originalBaseStyle: BaseStyle pendingLayers: LayerWithMetaData[] = [] subscribedLiveLayers: LayerNode[] = [] protected _baseStyleOpacity: number private _pendingBaseStyleOpacity: number protected wss: object = {} // WebSocketSubject symbolTextSize: ExpressionSpecification = [ 'interpolate', ["linear"], ['zoom'], 14, 13, 20, 24 ] constructor( public configService: ConfigService, protected route: ActivatedRoute, protected router: Router, protected ngZone: NgZone, private cdr: ChangeDetectorRef, public snackBar: MatSnackBar, protected _geoJsonService: GeoJsonService, protected _liveGeoJsonService: LiveGeoJsonService, protected mapDataService: MapDataService, public mapControlService: MapControlService, protected infoDataService: InfoDataService, ) {} @Input() set baseStyleName(styleName: string) { this._baseStyleName = styleName this.mapDataService.getBaseStyle(styleName).subscribe( (baseStyle: BaseStyle) => { // XXX: this assumes mapbox map is loaded this.originalBaseStyle = baseStyle if (this.map) { // Copy the base style for reference (eg. apply opacities) let oldStyle = this.map.getStyle() // Copy the layers to the new source for (let store in this.layers) { let highlightedStore = store + '-highlighted' let tagsStore = store + '-tags' let layer = oldStyle.layers.find(l => l.id == store) let highlightedLayer = oldStyle.layers.find(l => l.id == highlightedStore) let source = (this.map.getSource(store)).serialize() let highlightedSource = (this.map.getSource(highlightedStore)).serialize() baseStyle.style['sources'][store] = source baseStyle.style['sources'][highlightedStore] = highlightedSource //layer['source'] = source //highlightedLayer['source'] = source baseStyle.style['layers'].push(layer) baseStyle.style['layers'].push(highlightedLayer) let tagsSourceRaw = (this.map.getSource(tagsStore)) if (tagsSourceRaw) { let tagsLayer = oldStyle.layers.find(l => l.id == tagsStore) baseStyle.style['sources'][tagsStore] = tagsSourceRaw.serialize() baseStyle.style['layers'].push(tagsLayer) } } setTimeout(() => this.applyBaseStyleOpacity(), 0) } this.baseStyle = baseStyle this.cdr.markForCheck() } ) } get baseStyleName() { return this._baseStyleName } @Input() get permaLink() { if (!this.map) return let baseUrl = window.location.origin + '/' + 'map' + '?' let center = this.map.getCenter() let data = {} let ret = [] data['lat'] = center['lat'].toFixed(6) data['lng'] = center['lng'].toFixed(6) data['zoom'] = this.map.getZoom().toFixed(2) data['pitch'] = this.map.getPitch().toFixed(1) data['bearing'] = this.map.getBearing().toFixed(1) data['baseStyleName'] = this.baseStyleName data['baseStyleOpacity'] = this.baseStyleOpacity.toFixed(2) data['layers'] = this.getLayersForUrl().join(',') for (let d in data) { ret.push(encodeURIComponent(d) + '=' + encodeURIComponent(data[d])) } return baseUrl + ret.join('&') } protected getLayersForUrl(): string[] { return Object.keys(this.layers) } @Input() set baseStyleOpacity(opacity: number) { if (opacity == this._baseStyleOpacity) return if (!this.map) { if (!this._pendingBaseStyleOpacity) { this._pendingBaseStyleOpacity = opacity } return } this._baseStyleOpacity = opacity this.applyBaseStyleOpacity() } get baseStyleOpacity(): number { return this._baseStyleOpacity } _getNewSingleOpacity(layerId: string, originalOpacity: number | object): number | object { if (typeof(originalOpacity) === 'number') { return originalOpacity * this._baseStyleOpacity } else { let newOpacity = {} for (const k in originalOpacity) { let v = originalOpacity[k] if (k == 'stops') { newOpacity[k] = v.map(stop => [stop[0], stop[1] * this._baseStyleOpacity]) } else { newOpacity[k] = v } } return newOpacity } } _getNewOpacity(layer: object): object { let originalStyle = this.originalBaseStyle.style['layers'].find( (_layer: object) => layer['id'] == _layer['id'] ) if (!('paint' in originalStyle)) { originalStyle['paint'] = {} } if (['raster', 'background', 'fill', 'line'].indexOf(layer['type']) != -1) { let prop = layer['type'] + '-opacity' return { [prop]: this._getNewSingleOpacity(layer['id'], originalStyle['paint'][prop] || 1.0) } } else { let prop1 = 'text-opacity' let prop2 = 'icon-opacity' let newOpacity1 = this._getNewSingleOpacity(layer['id'], originalStyle['paint'][prop1] || 1.0) let newOpacity2 = this._getNewSingleOpacity(layer['id'], originalStyle['paint'][prop2] || 1.0) return { [prop1]: newOpacity1, [prop2]: newOpacity2, } } } applyBaseStyleOpacity() { for (let bsLayer of this.baseStyle.style['layers']) { // Skip our layers if (this.layers[bsLayer['id']] || bsLayer['id'].endsWith('-highlighted') || bsLayer['id'].endsWith('-tags') || bsLayer['id'].endsWith('-labels')) { continue } for (const [key, value] of Object.entries(this._getNewOpacity(bsLayer))) { this.map.setPaintProperty(bsLayer.id, key, value) } } } /* * For reference: routeQueryParamSetter = ['lat', 'lng', 'zoom', 'pitch', 'baseStyleName', 'bearing', 'baseStyleOpacity', 'layers'] */ ngOnInit() { this.route.queryParams.subscribe( (params: Params) => { if ('lat' in params) { this.lat = parseFloat(params['lat']) } if ('lng' in params) { this.lng = parseFloat(params['lng']) } if ('zoom' in params) { this.zoom = parseFloat(params['zoom']) } if ('pitch' in params) { this.pitch = parseFloat(params['pitch']) } if ('bearing' in params) { this.bearing = parseFloat(params['bearing']) } if ('baseStyleOpacity' in params) { this._pendingBaseStyleOpacity = parseFloat(params['baseStyleOpacity']) } if ('baseStyleName' in params) { this.baseStyleName = params['baseStyleName'] } else { this.baseStyleName = this._baseStyleName } if ('layers' in params) { this.mapControlService.mapReady$.subscribe( _ => { this.mapControlService.addBaseMapLayers( params['layers'].split(',').map((store: string) => new Store(store)) ) } ) } if ('show' in params) { let featureRef: string[] = params['show'].split(':') let store = featureRef[0] let field = featureRef[1] let value = featureRef[2] this.mapControlService.mapReady$.subscribe( _ => this.mapControlService.showFeature.next( new FeatureWithField(store, field, value) ) ) } this.cdr.markForCheck() } ) /*** Subscriptions ***/ this.mapControlService.baseStyleOpacity$.subscribe( baseStyleOpacity => { this.baseStyleOpacity = baseStyleOpacity } ) this.mapControlService.baseStyleName$.subscribe( baseStyleName => { this.baseStyleName = baseStyleName } ) this.mapControlService.layerAdd$.subscribe( (layerNode: LayerNode) => { layerNode.mapSubscription = this.addLayer(layerNode).subscribe( _ => { this.setAttributions() this.mapControlService.layerLoaded.next(layerNode) } ) } ) this.mapControlService.layerRemove$.subscribe( (layerNode: LayerNode) => { this.removeLayer(layerNode) layerNode.cancelDownload() } ) this.mapControlService.filter$.subscribe( ([searchText, taggedFeatures, taggedFeatureText]) => { this.filter(searchText, taggedFeatures, taggedFeatureText) } ) this.mapControlService.resize$.subscribe( flag => { this.map && this.map.resize() } ) this.mapControlService.zoomToFeatures$.subscribe( flag => { this.zoomToFeatures() } ) this.mapControlService.hasLabels$.subscribe( flag => { if (flag) { this.addLabelsLayers(this.mapControlService.selectedLayers) } else { this.removeLabelsLayers() } } ) this.mapControlService.hasTags$.subscribe( flag => { if (flag) { this.infoDataService.getTaggedLayers( this.mapControlService.selectedLayers, true ).subscribe( (taggedLayers: TaggedLayer[]) => this.addTagsLayers(taggedLayers) ) } else { this.removeTaggedLayers() } } ) this.mapControlService.status$.subscribe( status => { this.filterByStatus(status) } ) this.mapControlService.tempSelection$.subscribe( features => { for (let layerName in this.highlightedLayers) { this.highlightFeatures(this.layers[layerName], features[layerName]) } } ) this.mapControlService.selection$.subscribe( features => { // Verse tempSelection to selection this.mapControlService.tempSelection.next(features) } ) // Got tags, add layers this.infoDataService.taggedLayers$.subscribe( taggedLayers => this.addTagsLayers(taggedLayers) ) } onLoad(map: Map) { this.map = map if (!this.map) { console.error('Something quite wrong with the map init') return } this.map.on('mousemove', evt => this.onMove(evt)) this.map.on('mouseleave', evt => this.onLeave(evt)) this.map.getCanvasContainer().addEventListener('keydown', evt => this.onKeyDown(evt)) if (this._pendingBaseStyleOpacity) { this.mapControlService.baseStyleOpacitySlider.next(this._pendingBaseStyleOpacity) this.baseStyleOpacity = this._pendingBaseStyleOpacity this._pendingBaseStyleOpacity = undefined } else { this.applyBaseStyleOpacity() } // Set up a trigger for adding more layers this.map.on('style.load', _ => this.addLayers()) this.pendingLayers.forEach(layerAndDef => this.addLayerOnMap(layerAndDef)) this.canvas = map.getCanvasContainer() // Trigger the event / observable this.mapControlService.mapLoaded.complete() } /* * Feature selection (with alt key down) */ private _mousePos(evt): Point { var rect = this.canvas.getBoundingClientRect(); return new Point( evt.clientX - rect.left - this.canvas.clientLeft, evt.clientY - rect.top - this.canvas.clientTop ) } onMouseDown(evt) { // Continue the rest of the function if the altKey is pressed. if (!(evt.originalEvent.altKey && evt.originalEvent.button === 0 && this.canvas)) return // Disable default drag zooming when the altKey is held down. this.map.dragPan.disable() // Capture the first xy coordinates this.selStart = this._mousePos(evt.originalEvent) // Make sure that the selBox is initiated (takes care of case of a single click) this.onMouseMove(evt) // Remember if the new selBox should append to the current selection this.selAppend = evt.originalEvent.shiftKey } onMouseMove(evt) { // Capture the ongoing xy coordinates if (!this.inSelectionMode()) return // Append the selBox element if it doesnt exist if (!this.selBox) { this.selBox = document.createElement('div') this.selBox.classList.add('boxdraw') this.canvas.appendChild(this.selBox) } let selCurrent: Point = this._mousePos(evt.originalEvent) var minX = Math.min(this.selStart.x, selCurrent.x), maxX = Math.max(this.selStart.x, selCurrent.x), minY = Math.min(this.selStart.y, selCurrent.y), maxY = Math.max(this.selStart.y, selCurrent.y) // Adjust width and xy position of the selBox element ongoing var pos = 'translate(' + minX + 'px,' + minY + 'px)' this.selBox.style.transform = pos this.selBox.style.webkitTransform = pos this.selBox.style.width = maxX - minX + 'px' this.selBox.style.height = maxY - minY + 'px' this.selectFeaturesInBbox(this.selStart, this._mousePos(evt.originalEvent)) } onMouseUp(evt) { // Capture xy coordinates if (this.inSelectionMode()) { this.selFinish() } } inSelectionMode() { return !!this.selStart } onKeyDown(evt) { // If the ESC key is pressed if (evt.keyCode === 27) { this.mapControlService.selectFeatures([]) this.mapControlService.selectFeaturesTemp([]) if (this.inSelectionMode()) { this.selFinish() } } } selectFeaturesInBbox(point1: Point, point2: Point) { let featureList = this.map.queryRenderedFeatures( [point1, point2], {layers: Object.keys(this.layers)} ) // XXX: there should be a better method to build the dict of selected features, eg. map-reduce let features: object = {} for (let feature of featureList) { if (!features.hasOwnProperty(feature.layer.id)) { features[feature.layer.id] = new Set() } features[feature.layer.id].add(feature.id || feature.properties['id']) } if (this.selAppend) { let previousSel = this.mapControlService.selection.getValue() for (let layerName in previousSel) { if (features.hasOwnProperty(layerName)) { features[layerName] = new Set([ ...features[layerName], ...previousSel[layerName] || [] ]) } else { features[layerName] = previousSel[layerName] } } } // Emit an event with the features, for the info panel and highlight layers this.mapControlService.selectFeaturesTemp(features) } selFinish() { this.mapControlService.selectFeatures(this.mapControlService.tempSelection.getValue()) if (this.selBox) { this.selBox.parentNode.removeChild(this.selBox); this.selBox = null; this.selStart = null; } this.map.dragPan.enable(); } ngOnDestroy() { //this.map.removeHash() // Remove tagged layers this.infoDataService.taggedLayers.next([]) } disconnectLive() { this._liveGeoJsonService.disconnect() } addLayers() { // Add the layers already selected for (let layerName in this.layers) { let layer: LayerSpecification = this.layers[layerName] this.ngZone.runOutsideAngular( () => { this.map.addLayer(layer, this.getLayerNameBefore(this.layerDefs[layerName])) } ) } } getLayerNameBefore(layerDef) { if (!layerDef.zIndex) { return } let layersAfter = Object.keys(this.layerDefs) .map(k => this.layerDefs[k]) .filter(l => l.zIndex && (l.zIndex > layerDef.zIndex)) .sort((l1, l2) => l1.zIndex - l2.zIndex) if (layersAfter.length > 0) { return layersAfter[0].store } } setAttributions() { const attributions = Object.values(this.layers).map(l => l['attribution']) const uniques = [...new Set(attributions)] this.customAttributionElement.nativeElement.innerHTML = uniques.join(', ') } addLayer(layerNode: LayerNode, simplify: number = 0.2): Observable { /* * All the hard work formatting the data and style to MapBox */ let layerDef = layerNode return this._geoJsonService.getAll(layerNode.getUrl(), layerNode.store, { simplify: String(simplify) }).pipe(map( (resp: MapboxDataAndStyle) => { let data = resp.data if (!data['features'] || data['features'].length == 0) { console.log('Empty layer', layerDef.store) return } let mapboxPaint = resp.style?.paint let mapboxLayout = resp.style?.layout this.layerDefs[layerDef.store] = layerDef // Make sure ids are not in properties (like postGis to_GeoJson) // because Mapbox doesn't like strings as ids if (!data['features'][0]['id'] && data['features'][0]['properties']['id']) { /* data['features'].forEach( (feature:Object) => { feature['id'] = feature['properties']['id'] } ) */ } else { data['features'].forEach( (feature:object) => { feature['properties']['id'] = feature['id'] } ) } // Add a property to each feature, keeping track of selection data['features'].forEach( (feature:object) => feature['properties']['_selected'] = false ) let layer: LayerSpecification = { id: layerDef.store, type: layerDef.type, source: { type: "geojson", data: data }, attribution: resp.style?.attribution } this.layers[layerDef.store] = layer let highlightedLayer: LayerSpecification = { id: layerDef.store + '-highlighted', type: layerDef.type, source: { type: "geojson", data: data }, filter: ["==", "_selected", true] } this.highlightedLayers[layerDef.store] = highlightedLayer let layerX = new LayerWithMetaData(layer, highlightedLayer, layerNode) if (layerDef.type == 'symbol') { layer['layout'] = { 'text-line-height': 1, 'text-padding': 0, 'text-allow-overlap': true, 'text-field': layerDef.symbol || '\ue32b', 'icon-optional': true, 'text-font': ['GisafSymbols'], 'text-size': this.symbolTextSize, } layer['paint'] = { 'text-translate-anchor': 'viewport', 'text-color': '#000000' } highlightedLayer['layout'] = { 'text-line-height': 1, 'text-padding': 0, 'text-allow-overlap': true, 'text-field': layerDef.symbol || '\ue32b', 'icon-optional': true, 'text-font': ['GisafSymbols'], 'text-size': this.symbolTextSize, } highlightedLayer['paint'] = { 'text-translate-anchor': 'viewport', 'text-color': 'red' } } else if (layerDef.type == 'fill') { layer['paint'] = { 'fill-color': 'blue', 'fill-opacity': 0.50, } highlightedLayer['paint'] = { 'fill-color': 'red', 'fill-opacity': 0.70, } } else if (layerDef.type == 'line') { layer['paint'] = { 'line-color': 'red', 'line-opacity': 0.70, 'line-width': 2, 'line-blur': 0.5, } highlightedLayer['paint'] = { 'line-color': 'red', 'line-opacity': 0.90, 'line-width': 2, 'line-blur': 0.5, } } else if (layerDef.type == 'circle') { layer['paint'] = { 'circle-blur': 0.5, 'circle-opacity': 0.7, 'circle-color': '#404', 'circle-radius': 10, } highlightedLayer['paint'] = { 'circle-blur': 0.5, 'circle-opacity': 0.9, 'circle-color': 'red', 'circle-radius': 10, } } else if (layerDef.type == 'fill-extrusion') { layer['paint'] = { 'fill-extrusion-height': 5, 'fill-extrusion-opacity': 0.7, } highlightedLayer['paint'] = { 'fill-extrusion-height': (mapboxPaint && mapboxPaint['fill-extrusion-height']) || 5, 'fill-extrusion-opacity': 0.9, 'fill-extrusion-color': 'red', } } if (mapboxPaint) { layer['paint'] = mapboxPaint } if (mapboxLayout) { layer['layout'] = mapboxLayout } if (this.map) { this.addLayerOnMap(layerX) } else { this.pendingLayers.push(layerX) } if (this.mapControlService.hasLabels.value) { this.addLabelsLayer(layerDef) } if (this.mapControlService.hasTags.value) { this.getAndAddTagsLayer(layerDef) } } )) } zoomToAndInfoFeature(layerX: LayerWithMetaData) { // Zoom to the feature, if it's in the layer let featuretoShow = this.mapControlService.showFeature.value if (!featuretoShow) return if (featuretoShow.store != layerX.layerNode.store) return let id: string let featureData: object[] if (featuretoShow.field === 'id') { id = featuretoShow.value // XXX: remove typings (maplibre 2.x) featureData = [(layerX.layer).source.data['features'].find( (f: object) => f['id'] == id || f['properties']['id'] == id )] } else { featureData = (layerX.layer).source.data['features'].filter( (f: object) => f['properties'][featuretoShow.field] == featuretoShow.value || f[featuretoShow.field] == featuretoShow.value ) // Select only the first matching id id = featureData[0]['id'] } // Zoom if (featureData.length > 0 && !!featureData[0]) { // Show info this.mapControlService.featureClicked.next(new Feature(featuretoShow.store, id)) // Clear to prevent zooming again this.mapControlService.showFeature.next(undefined) // XXX: zoomToFeatures should actually be called when the layer is loaded // Using setTimeout as workaround setTimeout( () => this.zoomToFeatures(featureData), 700 ) } } addLayerOnMap(layerX: LayerWithMetaData) { // Zoom and info of the feature to show (as per the router query), // if that layer matches the store of that feature this.zoomToAndInfoFeature(layerX) if (!!this.map.getLayer(layerX.layerNode.store)) { console.debug('Layer already on the map', layerX.layerNode) return } this.ngZone.runOutsideAngular( () => { this.map.addLayer(layerX.layer, this.getLayerNameBefore(layerX.layerNode)) this.map.addLayer(layerX.highlightedLayer) } ) this.filterByStatus(this.mapControlService.status.value, layerX.layerNode.store, layerX.layerNode) // Register events for that layer this.map.on('mouseenter', layerX.layer['id'], evt => this.onEnter(evt)) this.map.on('mousemove', layerX.layer['id'], evt => this.onMoveOnLayer(evt)) this.map.on('mouseleave', layerX.layer['id'], evt => this.onLeaveOnLayer(evt)) // Live layer if (layerX.layerNode.live) { this.subscribedLiveLayers.push(layerX.layerNode) this.addLiveLayerListener(layerX.layerNode) } this.setAttributions() } addLiveLayerListener(layerDef: LayerNode) { let ws: WebSocketSubject = this._liveGeoJsonService.connect(layerDef.store) this.wss[layerDef.store] = ws ws.next({ 'message': 'subscribeLiveLayer', }) ws.subscribe({ next: (data: GeoJSON.FeatureCollection) => { let source: Source = this.map.getSource(layerDef.store) if (!source) { // Unsubscribe from channel on server console.warn('Live WS: cannot getSource for WS message: ', layerDef.store, data) ws.next({ 'message': 'unsubscribeLiveLayer', }) return } (source).setData(data) }, error: err => { console.error('Websocket', layerDef.store, err) //this.snackBar.open('Lost contact with Gisaf live server', 'OK') } }) } removeLiveLayerListener(layerDef: LayerNode) { let ws: WebSocketSubject = this.wss[layerDef.store] if (!ws) { return } ws.next({ 'message': 'unsubscribeLiveLayer', }) delete this.wss[layerDef.store] } removeLayer(layerDef: LayerNode) { // See https://github.com/mapbox/mapbox-gl-js/issues/4466 this.removeLayersByIds([ layerDef.store, layerDef.store + '-tags', layerDef.store + '-labels', ]) if (layerDef.store in this.highlightedLayers) { this.removeLayersByIds([this.highlightedLayers[layerDef.store]['id']]) } delete this.layerDefs[layerDef.store] delete this.layers[layerDef.store] delete this.highlightedLayers[layerDef.store] if (layerDef.live) { this.removeLiveLayerListener(layerDef) } this.setAttributions() } filter(searchText: string, taggedFeatures: TaggedLayer[], taggedFeatureText: string): void { for (let layerName in this.layers) { this.filterLayer(layerName, normalize(searchText), taggedFeatures, normalize(taggedFeatureText)) } } filterLayer(layerName: string, searchText: string, taggedFeatures: TaggedLayer[]=[], taggedFeatureText: string) { let layer = this.layers[layerName] let tagsLayerName = layerName + '-tags' let labelsLayerName = layerName + '-labels' let hasTagsLayer = !!this.map.getLayer(tagsLayerName) let hasLabelsLayer = !!this.map.getLayer(labelsLayerName) let filteredIds: any[] if (!searchText && !taggedFeatureText) { // Clear all filters this.map.setFilter(layerName, null) hasTagsLayer && this.map.setFilter(tagsLayerName, null) hasLabelsLayer && this.map.setFilter(labelsLayerName, null) return } if (searchText) { // TODO: regex matching (waiting for https://github.com/mapbox/mapbox-gl-js/pull/6228) //this.map.setFilter(layerName, ['regex-test', 'popup', searchText]) //this.map.setFilter(layerName, ['==', 'popup', searchText]) // Layer from JSON let filtered = layer.source.data.features.filter( feature => normalize(feature.properties.popup).indexOf(searchText) > -1 ) // Feature ids are converted to int in mapbox for some reason (https://github.com/mapbox/geojson-vt/pull/60) filteredIds = filtered.map(feature => feature.id || feature.properties.id) } else { filteredIds = [] } // Features with matching tags let taggedLayerFeatures = taggedFeatures.find(t => t.store == layerName) if (taggedLayerFeatures) { filteredIds.push(...taggedLayerFeatures.features.filter( feature => feature.tags.filter( tag => ( normalize(tag.key).indexOf(taggedFeatureText) > -1) || (normalize(tag.value).indexOf(taggedFeatureText) > -1 ) ).length > 0 ).map(feature => feature.id)) } if (filteredIds.length > 0) { // Filter the feature layers this.map.setFilter( layerName, // Filter expressions using 'match' seem not support $id, using 'in' ['in', 'id', ...filteredIds] ) // Filter the tags layers hasTagsLayer && this.map.setFilter(tagsLayerName, ['in', 'id', ...filteredIds]) // Filter the labels layers hasLabelsLayer && this.map.setFilter(labelsLayerName, ['in', 'id', ...filteredIds]) // TODO: filter the selection layers } else { // Nothing found, set filters as-is (should be none) this.map.setFilter(layerName, ['==', 'popup', searchText]) hasTagsLayer && this.map.setFilter(tagsLayerName, ['==', 'popup', taggedFeatureText]) hasLabelsLayer && this.map.setFilter(labelsLayerName, ['==', 'popup', taggedFeatureText]) } } highlightFeatures(layer, ids) { // Filter the features matching the ids in the related highlighted layer if (!layer) { console.debug('TODO: remove layer from highlightedLayers') return } let highlightedLayer = this.highlightedLayers[layer['id']] let idsString = new Set(Array.from(ids || []).map(id => id.toString())) let allFeatures = layer['source']['data']['features'] allFeatures.forEach( feature => feature['properties']['_selected'] = idsString.has(feature['id'] || feature['properties']['id']) ) // XXX: remove typings (maplibre 2.x) let hlayer = this.map.getSource(highlightedLayer.id) if (hlayer) { hlayer.setData({ type: 'FeatureCollection', features: allFeatures }) } else { console.log(highlightedLayer) } } getAndAddTagsLayer(tagLayerNode: LayerNode) { this.infoDataService.getTaggedLayers([tagLayerNode], true).subscribe( (taggedLayers: TaggedLayer[]) => Object.values(taggedLayers).forEach( taggedLayer => this.addTagsLayer(taggedLayer) ) ) } addLabelsLayer(layerNode: LayerNode) { let layerName = layerNode.store + '-labels' let layer = this.layers[layerNode.store] let source = layer['source']['data'] let features = source['features'] let labelsFeatures = features.map( feature => { return { 'id': feature['id'], 'type': 'Feature', 'properties': { 'id': feature['id'], 'label': feature['properties']['label'] || feature['properties']['popup'] }, 'geometry': feature['geometry'] } } ) //this.map.addSource(layerName, { // 'type': 'geojson', // 'data': { // 'type': 'FeatureCollection', // 'features': labelsFeatures, // } //}) let labelsLayer = { 'id': layerName, 'type': 'symbol', 'minzoom': 13, 'source': { 'type': 'geojson', 'data': { 'type': 'FeatureCollection', 'features': labelsFeatures, } }, 'layout': { 'text-field': ['get', 'label'], 'text-line-height': 1, 'text-padding': 0, 'text-anchor': 'bottom', 'text-offset': [0, 0], 'text-allow-overlap': false, 'text-font': ['Noto Sans Regular'], 'text-size': { stops: [[13, 9], [20, 18]] }, 'symbol-placement': layerNode.type == 'line' ? 'line' : 'point', }, 'paint': { 'text-translate-anchor': 'viewport', 'text-color': '#222266', 'text-halo-color': 'white', 'text-halo-width': 1, 'text-halo-blur': 0, } } this.ngZone.runOutsideAngular( () => this.map.addLayer(labelsLayer) ) } addTagsLayer(taggedLayer: TaggedLayer) { let layerName = taggedLayer.store + '-tags' if (!!this.map.getLayer(layerName)) { this.removeTaggedLayer(taggedLayer.store) } // Filter only features really in the layer let taggedFeatureIds = taggedLayer.features.map(ff => ff.id) let features = this.layers[taggedLayer.store].source.data.features.filter( (f: object) => taggedFeatureIds.findIndex( fff => fff == f['id'] || fff == f['properties']['id'] ) !== -1 ) let taggedLayerFeatures = taggedLayer.features.filter( (f: TaggedFeature) => features.findIndex( (ff: object) => ff['id'] === f.id || ff['properties']['id'] === f.id) != -1 ) let tagsLayer = { id: layerName, type: 'symbol', minzoom: 13, source: { type: "geojson", data: { type: 'FeatureCollection', features: taggedLayerFeatures.map( feature => { return { type: 'Feature', properties: { id: feature.id, tag: feature.tags.map(tag => tag.key + ': ' + tag.value).join(', ') }, geometry: { type: "Point", coordinates: [feature.lon, feature.lat] } } } ) } }, layout: { 'text-field': ['get', 'tag'], 'text-line-height': 1, 'text-padding': 0, 'text-anchor': 'bottom', 'text-offset': [0, -1], 'text-allow-overlap': false, 'text-font': ['Noto Sans Regular'], 'text-size': { stops: [[13, 9], [20, 18]] } }, paint: { 'text-translate-anchor': 'viewport', 'text-color': '#662222', 'text-halo-color': 'white', 'text-halo-width': 1, 'text-halo-blur': 0, } } this.ngZone.runOutsideAngular( () => { this.map.addLayer(tagsLayer) } ) } addLabelsLayers(layers: LayerNode[]) { // XXX: taggedLayers in MapControlComponent behaves like an object Object.values(layers).forEach( labelsLayer => this.addLabelsLayer(labelsLayer) ) } addTagsLayers(taggedLayers: TaggedLayer[]) { // XXX: taggedLayers in MapControlComponent behaves like an object Object.values(taggedLayers).forEach( taggedLayer => this.addTagsLayer(taggedLayer) ) } removeLabelsLayers() { Object.keys(this.layerDefs).forEach( layerId => this.removeLabelsLayer(layerId) ) } removeTaggedLayers() { Object.keys(this.layerDefs).forEach( layerId => this.removeTaggedLayer(layerId) ) } removeLabelsLayer(layerId: string) { this.removeLayersByIds([layerId + '-labels']) } removeTaggedLayer(layerId: string) { this.removeLayersByIds([layerId + '-tags']) } protected removeLayersByIds(layerIds: string[]) { layerIds.forEach( layerId => { if (!!this.map.getLayer(layerId)) this.map.removeLayer(layerId) if (!!this.map.getSource(layerId)) this.map.removeSource(layerId) } ) } filterByStatus(status: string[], layer?: string, layerNode?: LayerNode) { for (let layerName of layer ? [layer] : Object.keys(this.layers)) { let layerNode = this.layerDefs[layerName] if (!layerNode.custom && !layerNode.live) { this.map.setFilter(layerName, ['in', 'status', ...status]) } } } /* toggleInfoDrawer(): void { this.mapControlService.toggleInfo() } */ toggleControlDrawer(value?:boolean): void { this.mapControlService.toggleControls(value) } onClick(evt: MapMouseEvent): void { if (!(evt.point && this.map)) { return } let features = this.map.queryRenderedFeatures(evt.point, {layers:Object.keys(this.layers)}) let feature = features[0] if (feature) { let layer = (this.layerDefs)[(feature).layer.id] let store: string = layer.live && feature['properties']['store'] ? feature['properties']['store'] : layer.store this.mapControlService.featureClicked.next( new Feature(store, feature['properties']['id'] || feature.id) ) } else { this.mapControlService.featureClicked.next(undefined) } } onMove(evt: MapMouseEvent): void { this.mouseLngLat.nativeElement.innerHTML = evt.lngLat.lng.toFixed(7) + ', ' + evt.lngLat.lat.toFixed(7) } onLeave(evt: MapMouseEvent): void { this.mouseLngLat.nativeElement.innerHTML = null } onMoveOnLayer(evt: MapMouseEvent): void { let feature = (evt).features[0] var rfeature = this.map.queryRenderedFeatures(evt.point)[0] if (rfeature.id !== this.popupOnFeature) { this.popupOnFeature = rfeature.id this.featureInfo.nativeElement.innerHTML = feature.properties.popup } } onEnter(evt: MapMouseEvent): void { let feature = (evt).features[0] this.popupOnFeature = feature.id let info = feature.properties.popup this.featureInfo.nativeElement.innerHTML = info } onLeaveOnLayer(evt: MapMouseEvent): void { this.popupOnFeature = null this.featureInfo.nativeElement.innerHTML = null } zoomToFeatures(features?): void { // Zoom to the given features or all the visible ones const fitBoundsOptions = { padding: 40, maxZoom: 17, } if (!features) { // Zoom to visible features // Collect all the features features = [] for (let layerName in this.layers) { let filter = this.map.getFilter(layerName) // Eventually re-fit with the filter if (filter && filter[0]=='in' && filter[1]=='id') { // NOTE: this.map.querySourceLayer works only for features in the viewport, so // the source data is used // Assuming that filter is in the form ['in', 'id', ...] let ids = (filter).splice(2) features.push(...this.layers[layerName].source.data.features.filter(f=>ids.indexOf(f.id)!=-1)) } else { features.push(...this.layers[layerName].source.data.features) } } } // XXX: bug in turf: https://github.com/Turfjs/turf/issues/1428 let bb = bbox.default({ type: 'FeatureCollection', features: features }) if (!bb.some(b => b == Infinity)) { this.map.fitBounds([[bb[0], bb[1]], [bb[2], bb[3]]], fitBoundsOptions) } } }