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 '../../_services/config.service'
import { MapControlService } from '../map-control.service'
import { LayerNode } from '../models'
import { InfoDataService, Feature, TaggedLayer, FeatureWithField, TaggedFeature } from '../../info/info-data.service'
import { 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.bsData?.map['zoom']
  pitch: number = this.configService.conf.value.bsData?.map['pitch']
  lat: number = this.configService.conf.value.bsData?.map['lat']
  lng: number = this.configService.conf.value.bsData?.map['lng']
  bearing: number = this.configService.conf.value.bsData?.map['bearing']
  globalAttribution: string = this.configService.conf.value.bsData?.map['attribution']

  baseStyle: BaseStyle = new BaseStyle('None')
  protected _baseStyleName: string = this.configService.conf.value.bsData?.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<object>

  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 = (<any>this.map.getSource(store)).serialize()
            let highlightedSource = (<any>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 = (<any>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 (originalOpacity === undefined) {
      return undefined
    }
    else if (typeof originalOpacity === 'number') {
      return originalOpacity * this._baseStyleOpacity
    }
    else if (typeof originalOpacity === 'object') {  // Also matches array
      if (originalOpacity[0] == 'interpolate') {
        // Change the last interpolation point
        originalOpacity[Object.values(originalOpacity).length - 1] = this._baseStyleOpacity
        return originalOpacity
      }
      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
      }
    }
    else {
      console.log(`Cannot process opacity of layer ${layerId}, unknown type ${typeof originalOpacity}`)
      return originalOpacity
    }
  }

  _getNewOpacity(layer: object): object {
    let originalStyle = this.originalBaseStyle.style['layers'].find(
      (_layer: object) => layer['id'] == _layer['id']
    )
    let originalPaint = originalStyle['paint'] || {}
    if (layer['type'] == 'symbol') {
      let prop1 = 'text-opacity'
      let prop2 = 'icon-opacity'
      let newOpacity1 = this._getNewSingleOpacity(layer['id'], originalPaint[prop1] || 1.0)
      let newOpacity2 = this._getNewSingleOpacity(layer['id'], originalPaint[prop2] || 1.0)
      return {
        [prop1]: newOpacity1,
        [prop2]: newOpacity2,
      }
    }
    else if (layer['type'] == 'line') {
      let prop = layer['type'] + '-opacity'
      return {
        [prop]: this._getNewSingleOpacity(layer['id'], originalPaint[prop] || 1.0)
      }
    }
    else {
      // layer['type'] in ['raster', 'background', 'fill', 'line', 'fill-extrusion']
      let prop = layer['type'] + '-opacity'
      return {
        [prop]: this._getNewSingleOpacity(layer['id'], originalPaint[prop] || 1.0),
      }
    }
  }

  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))) {
        if (value !== undefined) {
          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(',')
              )
            }
          )
        }
        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<any> {
    /*
     * 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 = <LayerSpecification>{
          id: layerDef.store,
          type: layerDef.type,
          source: <GeoJSONSourceSpecification>{
            type: "geojson",
            data: data
          },
          attribution: resp.style?.attribution
        }
        this.layers[layerDef.store] = layer

        let highlightedLayer: LayerSpecification = <LayerSpecification>{
          id: layerDef.store + '-highlighted',
          type: layerDef.type,
          source: <GeoJSONSourceSpecification>{
            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 && <number>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 = [(<any>layerX.layer).source.data['features'].find(
        (f: object) => f['id'] == id || f['properties']['id'] == id
      )]
    }
    else {
      featureData = (<any>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(<LayerSpecification>layerX.layer, this.getLayerNameBefore(layerX.layerNode))
        this.map.addLayer(<LayerSpecification>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<object> = 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
        }
        (<GeoJSONSource>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<object> = 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 = <any>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(<LayerSpecification>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(<LayerSpecification>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 = <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 = (<any>this.layerDefs)[(<any>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 = (<any>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 = (<any>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 = (<string[]>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)
    }
  }
}
