From b6550e5c9441ddc11cc8d624aa094905269802bc Mon Sep 17 00:00:00 2001
From: ncastejon <castejon.nicolas@gmail.com>
Date: Wed, 6 Jun 2018 13:26:07 +0200
Subject: [PATCH] Integrate WMS layer if WFS not exist. Try external lib
 feaflet.wms for WMS options

---
 webapp/.angular-cli.json                      |   3 +-
 webapp/package-lock.json                      |  42 ++
 webapp/package.json                           |   2 +
 .../dataset-map/dataset-map.component.scss    |   1 -
 .../dataset-map/dataset-map.component.ts      | 124 +++--
 .../app/geosource/models/metadata.model.ts    |   2 +-
 webapp/src/app/geosource/services/index.ts    |   4 +-
 .../src/app/geosource/services/map.service.ts |  17 +
 webapp/src/assets/leaflet.wms.js              | 495 ++++++++++++++++++
 webapp/src/tsconfig.app.json                  |   4 +-
 10 files changed, 656 insertions(+), 38 deletions(-)
 create mode 100644 webapp/src/app/geosource/services/map.service.ts
 create mode 100644 webapp/src/assets/leaflet.wms.js

diff --git a/webapp/.angular-cli.json b/webapp/.angular-cli.json
index 0ce40adc..a0ecf192 100644
--- a/webapp/.angular-cli.json
+++ b/webapp/.angular-cli.json
@@ -25,7 +25,8 @@
         "../node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
       ],
       "scripts": [
-        "../node_modules/leaflet.markercluster/dist/leaflet.markercluster.js"
+        "../node_modules/leaflet.markercluster/dist/leaflet.markercluster.js",
+        "../node_modules/proj4leaflet/lib/proj4-compressed.js"
       ],
       "environmentSource": "environments/environment.ts",
       "environments": {
diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index 4b1a8049..00dd7ed8 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -325,6 +325,21 @@
       "integrity": "sha512-EhNufyBoC1Kqaj+XMHGzi6mPUC8wVABOMTPE5XaSJc49LIVvXsyrV1HYMAPTUViT7E4wLUB38OdDmb+HshjGmA==",
       "dev": true
     },
+    "@types/proj4": {
+      "version": "2.3.4",
+      "resolved": "https://registry.npmjs.org/@types/proj4/-/proj4-2.3.4.tgz",
+      "integrity": "sha1-84i2AgnEy3XsIaF6S0rFWnGlhVg="
+    },
+    "@types/proj4leaflet": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/proj4leaflet/-/proj4leaflet-1.0.5.tgz",
+      "integrity": "sha512-xqw5V5wk0iSjpDSN2X1tuEo6qL4QhfffaTpnHN6ISz3vnT4Z4m4aKdH1vQHlpJ6aJ0TBvcRmI8X2/Lv7t/0qOw==",
+      "requires": {
+        "@types/geojson": "7946.0.3",
+        "@types/leaflet": "1.2.7",
+        "@types/proj4": "2.3.4"
+      }
+    },
     "@types/q": {
       "version": "0.0.32",
       "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz",
@@ -6943,6 +6958,11 @@
       "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
       "dev": true
     },
+    "mgrs": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
+      "integrity": "sha1-+5FYjnjJACVnI5XLQLJffNatGCk="
+    },
     "micromatch": {
       "version": "2.3.11",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz",
@@ -8425,6 +8445,23 @@
       "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
       "dev": true
     },
+    "proj4": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.4.4.tgz",
+      "integrity": "sha512-yo6qTpBQXnxhcPopKJeVwwOBRzUpEa3vzSFlr38f5mF4Jnfb6NOL/ePIomefWiZmPgkUblHpcwnWVMB8FS3GKw==",
+      "requires": {
+        "mgrs": "1.0.0",
+        "wkt-parser": "1.2.1"
+      }
+    },
+    "proj4leaflet": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/proj4leaflet/-/proj4leaflet-1.0.2.tgz",
+      "integrity": "sha512-6GdDeUlhX/tHUiMEj80xQhlPjwrXcdfD0D5OBymY8WvxfbmZcdhNqQk7n7nFf53ue6QdP9ls9ZPjsAxnbZDTsw==",
+      "requires": {
+        "proj4": "2.4.4"
+      }
+    },
     "promise": {
       "version": "7.3.1",
       "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@@ -12461,6 +12498,11 @@
       "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=",
       "dev": true
     },
+    "wkt-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.2.1.tgz",
+      "integrity": "sha512-c6iNYzlbWNXwtcZ+0DMy1AOSHxVKFPR4a8EBVOgVbDSeSEnz2gpicmXSnuql1tKgS67CY+ughyjprP8pckk5jg=="
+    },
     "wordwrap": {
       "version": "0.0.2",
       "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz",
diff --git a/webapp/package.json b/webapp/package.json
index 742daeb7..16e52070 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -32,11 +32,13 @@
     "@angular/router": "^5.2.0",
     "@types/leaflet": "^1.2.7",
     "@types/leaflet.markercluster": "^1.0.3",
+    "@types/proj4leaflet": "^1.0.5",
     "bulma": "^0.7.1",
     "core-js": "^2.4.1",
     "font-awesome": "^4.7.0",
     "leaflet": "^1.3.1",
     "leaflet.markercluster": "^1.3.0",
+    "proj4leaflet": "^1.0.2",
     "rxjs": "^5.5.6",
     "zone.js": "^0.8.19"
   },
diff --git a/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.scss b/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.scss
index 64d0b1d7..4fb3c0b7 100644
--- a/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.scss
+++ b/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.scss
@@ -1,4 +1,3 @@
 #frugalmap {
   height: 600px;
-  width: 80%
 }
diff --git a/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.ts b/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.ts
index da6cfc0b..bfa9bc44 100644
--- a/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.ts
+++ b/webapp/src/app/geosource/components/dataset-detail/dataset-map/dataset-map.component.ts
@@ -1,9 +1,12 @@
 import { Component, OnInit, Input } from '@angular/core';
 import * as L from 'leaflet';
 import 'leaflet.markercluster';
+import 'proj4leaflet';
 import { ActivatedRoute, ParamMap } from '@angular/router';
 import { DatasetService } from '../../../services';
 import { Metadata, Data } from '../../../models';
+import { MapService } from '../../../services/map.service';
+import * as wms from '../../../../../assets/leaflet.wms.js';
 
 @Component({
   selector: 'app-dataset-map',
@@ -14,21 +17,20 @@ export class DatasetMapComponent implements OnInit {
   metadata: Metadata;
 
   constructor(
-    private _route: ActivatedRoute,
-    private _datasetService: DatasetService,
+    private route: ActivatedRoute,
+    private datasetService: DatasetService,
+    private mapService: MapService
   ) { }
 
   ngOnInit() {
-    this._route.parent.paramMap
-      .switchMap((params: ParamMap) => this._datasetService.getMetadataById(params.get('id')))
+    this.route.parent.paramMap
+      .switchMap((params: ParamMap) => this.datasetService.getMetadataById(params.get('id')))
       .subscribe((metadata: Metadata) => {
-        this._datasetService.getDataByMetadataById(metadata.dataset_index).subscribe(results => {
-          this.constructMap(results);
-        });
+        this.constructMap(metadata);
 
       });
   }
-  constructMap(results: Data[]) {
+  constructMap(metadata: Metadata) {
     // Déclaration de la carte avec les coordonnées du centre et le niveau de zoom.
     const attribution = 'Data Grand Lyon';
     const satellite = L.tileLayer('https://openstreetmap.data.grandlyon.com/3857/tms/1.0.0/ortho2015@GoogleMapsCompatible/{z}/{x}/{-y}.png', { id: 'MapID', attribution: attribution });
@@ -56,33 +58,89 @@ export class DatasetMapComponent implements OnInit {
     const cluster = L.markerClusterGroup({
       maxClusterRadius: 120
     });
-    results.forEach(element => {
-      console.log(element);
-
-      switch (element.geometry.type) {
-        case 'Point':
-          const marker = L.marker([element.geometry.coordinates[1], element.geometry.coordinates[0]], { icon: myIcon })
-            .bindPopup(element.properties.nom).addTo(dataGrandLyonMap);
-          // group.push(marker);
-          cluster.addLayer(marker);
-          break;
-        case 'Polygon':
-          // create a red polygon from an array of LatLng points
-          element.geometry.coordinates.forEach(coordinates => {
-            coordinates.forEach(coord => {
-              coord.reverse();
-            });
-            const polygon = L.polygon(coordinates, { color: 'red' }).addTo(dataGrandLyonMap);
-            group.push(polygon);
-          });
-
-          break;
+    const hasWFS = metadata.uri.filter(function (e) { return e.protocol === 'OGC:WFS'; }).length > 0;
+    metadata.uri.forEach(element => {
+      if (hasWFS && element.protocol === 'OGC:WFS') {
+        const options = {
+          'name': element.name
+        };
+        this.mapService.getWFS(options).subscribe(results => {
+          console.log(results);
+          if (results.features[0].geometry.type === 'Point') {
+            L.geoJSON(results, {
+              pointToLayer: function (feature, latlng) {
+                return L.marker(latlng, { icon: myIcon }); // The basic style
+              }
+            }).addTo(dataGrandLyonMap);
+          } else if (results.features[0].geometry.type === 'Polygon') {
+            L.geoJSON(results, {
+              onEachFeature: function (feature, layer) {
+                let popContent = '';
+                for (const key in feature.properties) {
+                  if (feature.properties.hasOwnProperty(key)) {
+                    popContent += '<p>' + key + ': ' + feature.properties[key] + '</p>';
+                  }
+                }
+                layer.bindPopup(popContent);
+              }
+            }).addTo(dataGrandLyonMap);
+          }
+        });
+      } else if (!hasWFS && element.protocol === 'OGC:WMS') {
+        const crs = new L.Proj.CRS('EPSG:4171',
+      '+proj=longlat +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +no_defs ');
+      L.tileLayer.wms('https://download.data.grandlyon.com/wms/grandlyon?', {
+          crs: crs,
+          transparent: true,
+          layers: element.name,
+          format: 'image/png',
+          zIndex: 1000
+        }).addTo(dataGrandLyonMap);
+        // source.addSubLayer(element.name);
+        // source.addTo(dataGrandLyonMap);
       }
     });
-    dataGrandLyonMap.addLayer(cluster);
-    const featureGroup = L.featureGroup(group);
-    // zoom the map to the polygon
-    dataGrandLyonMap.fitBounds(featureGroup.getBounds());
+
+
+    // results.forEach(element => {
+    //   console.log(element);
+
+    //   switch (element.geometry.type) {
+    //     case 'Point':
+    //       const marker = L.marker([element.geometry.coordinates[1], element.geometry.coordinates[0]], { icon: myIcon })
+    //         .bindPopup(element.properties.nom).addTo(dataGrandLyonMap);
+    //       // group.push(marker);
+    //       cluster.addLayer(marker);
+    //       break;
+    //     case 'Polygon':
+    //       // create a red polygon from an array of LatLng points
+    //       element.geometry.coordinates.forEach(coordinates => {
+    //         coordinates.forEach(coord => {
+    //           coord.reverse();
+    //         });
+    //         const polygon = L.polygon(coordinates, { color: 'red' }).addTo(dataGrandLyonMap);
+    //         group.push(polygon);
+    //       });
+
+    //       break;
+    //   }
+    // });
+    // dataGrandLyonMap.addLayer(cluster);
+    // const featureGroup = L.featureGroup(group);
+    // // zoom the map to the polygon
+    // dataGrandLyonMap.fitBounds(featureGroup.getBounds());
     L.control.scale().addTo(dataGrandLyonMap);
   }
+
+  onEachFeature(feature, layer) {
+    // does this feature have a property named popupContent?
+    console.log(feature);
+    console.log(layer);
+    if (feature.properties && feature.properties.nom) {
+      layer.bindPopup(feature.properties.nom);
+    }
+    if (feature.properties && feature.properties.odentifiant) {
+      layer.bindPopup(feature.properties.identifiant);
+    }
+  }
 }
diff --git a/webapp/src/app/geosource/models/metadata.model.ts b/webapp/src/app/geosource/models/metadata.model.ts
index 836459bd..d2a12d6b 100644
--- a/webapp/src/app/geosource/models/metadata.model.ts
+++ b/webapp/src/app/geosource/models/metadata.model.ts
@@ -32,7 +32,7 @@ interface IContact {
 export interface IMetadataUri {
   'description': string;
   'url':  string;
-  'protocol':  Array<string>;
+  'protocol':  string;
   'name':  string;
 }
 
diff --git a/webapp/src/app/geosource/services/index.ts b/webapp/src/app/geosource/services/index.ts
index 422e7bd8..51982c44 100644
--- a/webapp/src/app/geosource/services/index.ts
+++ b/webapp/src/app/geosource/services/index.ts
@@ -1,9 +1,11 @@
 import { DatasetService } from './dataset.service';
 import { ElasticsearchService } from './elasticsearch.service';
+import { MapService } from './map.service';
 
 export { DatasetService, ElasticsearchService };
 
 export const GeosourceServices = [
   DatasetService,
-  ElasticsearchService
+  ElasticsearchService,
+  MapService
 ];
diff --git a/webapp/src/app/geosource/services/map.service.ts b/webapp/src/app/geosource/services/map.service.ts
new file mode 100644
index 00000000..1b5b5ae3
--- /dev/null
+++ b/webapp/src/app/geosource/services/map.service.ts
@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core';
+import { IfObservable } from 'rxjs/observable/IfObservable';
+import { Observable } from 'rxjs/Observable';
+import { HttpClient } from '@angular/common/http';
+
+@Injectable()
+export class MapService {
+
+  constructor(
+    private http: HttpClient
+  ) {}
+
+  getWFS(options): Observable<any> {
+    const url = 'https://download.data.grandlyon.com/wfs/grandlyon?SERVICE=WFS&VERSION=2.0.0&outputformat=GEOJSON&maxfeatures=30&request=GetFeature&typename=' + options.name + '&SRSNAME=urn:ogc:def:crs:EPSG::4171';
+    return this.http.get(url);
+  }
+}
diff --git a/webapp/src/assets/leaflet.wms.js b/webapp/src/assets/leaflet.wms.js
new file mode 100644
index 00000000..6a65d604
--- /dev/null
+++ b/webapp/src/assets/leaflet.wms.js
@@ -0,0 +1,495 @@
+/*!
+ * leaflet.wms.js
+ * A collection of Leaflet utilities for working with Web Mapping services.
+ * (c) 2014-2016, Houston Engineering, Inc.
+ * MIT License
+ */
+
+(function (factory) {
+  // Module systems magic dance, Leaflet edition
+  if (typeof define === 'function' && define.amd) {
+      // AMD
+      define(['leaflet'], factory);
+  } else if (typeof module !== 'undefined') {
+      // Node/CommonJS
+      module.exports = factory(require('leaflet'));
+  } else {
+      // Browser globals
+      if (typeof this.L === 'undefined')
+          throw 'Leaflet must be loaded first!';
+      // Namespace
+      this.L.WMS = this.L.wms = factory(this.L);
+  }
+}(function (L) {
+
+// Module object
+var wms = {};
+
+// Quick shim for Object.keys()
+if (!('keys' in Object)) {
+  Object.keys = function(obj) {
+      var result = [];
+      for (var i in obj) {
+          if (obj.hasOwnProperty(i)) {
+              result.push(i);
+          }
+      }
+      return result;
+  };
+}
+
+/*
+* wms.Source
+* The Source object manages a single WMS connection.  Multiple "layers" can be
+* created with the getLayer function, but a single request will be sent for
+* each image update.  Can be used in non-tiled "overlay" mode (default), or
+* tiled mode, via an internal wms.Overlay or wms.TileLayer, respectively.
+*/
+wms.Source = L.Layer.extend({
+  'options': {
+      'untiled': true,
+      'identify': true
+  },
+
+  'initialize': function(url, options) {
+      L.setOptions(this, options);
+      if (this.options.tiled) {
+          this.options.untiled = false;
+      }
+      this._url = url;
+      this._subLayers = {};
+      this._overlay = this.createOverlay(this.options.untiled);
+  },
+
+  'createOverlay': function(untiled) {
+      // Create overlay with all options other than untiled & identify
+      var overlayOptions = {};
+      for (var opt in this.options) {
+          if (opt != 'untiled' && opt != 'identify') {
+              overlayOptions[opt] = this.options[opt];
+          }
+      }
+      if (untiled) {
+          return wms.overlay(this._url, overlayOptions);
+      } else {
+          return wms.tileLayer(this._url, overlayOptions);
+      }
+  },
+
+  'onAdd': function() {
+      this.refreshOverlay();
+  },
+
+  'getEvents': function() {
+      if (this.options.identify) {
+          return {'click': this.identify};
+      } else {
+          return {};
+      }
+  },
+
+  'setOpacity': function(opacity) {
+       this.options.opacity = opacity;
+       if (this._overlay) {
+           this._overlay.setOpacity(opacity);
+       }
+  },
+  
+  'bringToBack': function() {
+       this.options.isBack = true;
+       if (this._overlay) {
+           this._overlay.bringToBack();
+       }
+  },
+
+  'bringToFront': function() {
+       this.options.isBack = false;
+       if (this._overlay) {
+           this._overlay.bringToFront();
+       }
+  },
+
+  'getLayer': function(name) {
+      return wms.layer(this, name);
+  },
+
+  'addSubLayer': function(name) {
+      this._subLayers[name] = true;
+      this.refreshOverlay();
+  },
+
+  'removeSubLayer': function(name) {
+      delete this._subLayers[name];
+      this.refreshOverlay();
+  },
+
+  'refreshOverlay': function() {
+      var subLayers = Object.keys(this._subLayers).join(",");
+      if (!this._map) {
+          return;
+      }
+      if (!subLayers) {
+          this._overlay.remove();
+      } else {
+          this._overlay.setParams({'layers': subLayers});
+          this._overlay.addTo(this._map);
+      }
+  },
+
+  'identify': function(evt) {
+      // Identify map features in response to map clicks. To customize this
+      // behavior, create a class extending wms.Source and override one or
+      // more of the following hook functions.
+
+      var layers = this.getIdentifyLayers();
+      if (!layers.length) {
+          return;
+      }
+      this.getFeatureInfo(
+          evt.containerPoint, evt.latlng, layers,
+          this.showFeatureInfo
+      );
+  },
+
+  'getFeatureInfo': function(point, latlng, layers, callback) {
+      // Request WMS GetFeatureInfo and call callback with results
+      // (split from identify() to faciliate use outside of map events)
+      var params = this.getFeatureInfoParams(point, layers),
+          url = this._url + L.Util.getParamString(params, this._url);
+
+      this.showWaiting();
+      this.ajax(url, done);
+
+      function done(result) {
+          this.hideWaiting();
+          var text = this.parseFeatureInfo(result, url);
+          callback.call(this, latlng, text);
+      }
+  },
+
+  'ajax': function(url, callback) {
+      ajax.call(this, url, callback);
+  },
+
+  'getIdentifyLayers': function() {
+      // Hook to determine which layers to identify
+      if (this.options.identifyLayers)
+          return this.options.identifyLayers;
+      return Object.keys(this._subLayers);
+   },
+
+  'getFeatureInfoParams': function(point, layers) {
+      // Hook to generate parameters for WMS service GetFeatureInfo request
+      var wmsParams, overlay;
+      if (this.options.untiled) {
+          // Use existing overlay
+          wmsParams = this._overlay.wmsParams;
+      } else {
+          // Create overlay instance to leverage updateWmsParams
+          overlay = this.createOverlay(true);
+          overlay.updateWmsParams(this._map);
+          wmsParams = overlay.wmsParams;
+          wmsParams.layers = layers.join(',');
+      }
+      var infoParams = {
+          'request': 'GetFeatureInfo',
+          'query_layers': layers.join(','),
+          'X': Math.round(point.x),
+          'Y': Math.round(point.y)
+      };
+      return L.extend({}, wmsParams, infoParams);
+  },
+
+  'parseFeatureInfo': function(result, url) {
+      // Hook to handle parsing AJAX response
+      if (result == "error") {
+          // AJAX failed, possibly due to CORS issues.
+          // Try loading content in <iframe>.
+          result = "<iframe src='" + url + "' style='border:none'>";
+      }
+      return result;
+  },
+
+  'showFeatureInfo': function(latlng, info) {
+      // Hook to handle displaying parsed AJAX response to the user
+      if (!this._map) {
+          return;
+      }
+      this._map.openPopup(info, latlng);
+  },
+
+  'showWaiting': function() {
+      // Hook to customize AJAX wait animation
+      if (!this._map)
+          return;
+      this._map._container.style.cursor = "progress";
+  },
+
+  'hideWaiting': function() {
+      // Hook to remove AJAX wait animation
+      if (!this._map)
+          return;
+      this._map._container.style.cursor = "default";
+  }
+});
+
+wms.source = function(url, options) {
+  return new wms.Source(url, options);
+};
+
+/*
+* Layer
+* Leaflet "layer" with all actual rendering handled via an underlying Source
+* object.  Can be called directly with a URL to automatically create or reuse
+* an existing Source.  Note that the auto-source feature doesn't work well in
+* multi-map environments; so for best results, create a Source first and use
+* getLayer() to retrieve wms.Layer instances.
+*/
+
+wms.Layer = L.Layer.extend({
+  'initialize': function(source, layerName, options) {
+      L.setOptions(this, options);
+      if (!source.addSubLayer) {
+          // Assume source is a URL
+          source = wms.getSourceForUrl(source, options);
+      }
+      this._source = source;
+      this._name = layerName;
+  },
+  'onAdd': function() {
+      if (!this._source._map)
+          this._source.addTo(this._map);
+      this._source.addSubLayer(this._name);
+  },
+  'onRemove': function() {
+      this._source.removeSubLayer(this._name);
+  },
+  'setOpacity': function(opacity) {
+      this._source.setOpacity(opacity);
+  },
+  'bringToBack': function() {
+      this._source.bringToBack();
+  },
+  'bringToFront': function() {
+      this._source.bringToFront();
+  }
+});
+
+wms.layer = function(source, options) {
+  return new wms.Layer(source, options);
+};
+
+// Cache of sources for use with wms.Layer auto-source option
+var sources = {};
+wms.getSourceForUrl = function(url, options) {
+  if (!sources[url]) {
+      sources[url] = wms.source(url, options);
+  }
+  return sources[url];
+};
+
+
+// Copy tiled WMS layer from leaflet core, in case we need to subclass it later
+wms.TileLayer = L.TileLayer.WMS;
+wms.tileLayer = L.tileLayer.wms;
+
+/*
+* wms.Overlay:
+* "Single Tile" WMS image overlay that updates with map changes.
+* Portions of wms.Overlay are directly extracted from L.TileLayer.WMS.
+* See Leaflet license.
+*/
+wms.Overlay = L.Layer.extend({
+  'defaultWmsParams': {
+      'service': 'WMS',
+      'request': 'GetMap',
+      'version': '1.3.0',
+      'layers': '',
+      'styles': '',
+      'format': 'image/jpeg',
+      'transparent': false
+  },
+
+  'options': {
+      'crs': null,
+      'uppercase': false,
+      'attribution': '',
+      'opacity': 1,
+      'isBack': false,
+      'minZoom': 0,
+      'maxZoom': 18
+  },
+
+  'initialize': function(url, options) {
+      this._url = url;
+
+      // Move WMS parameters to params object
+      var params = {}, opts = {};
+      for (var opt in options) {
+           if (opt in this.options) {
+               opts[opt] = options[opt];
+           } else {
+               params[opt] = options[opt];
+           }
+      }
+      L.setOptions(this, opts);
+      this.wmsParams = L.extend({}, this.defaultWmsParams, params);
+  },
+
+  'setParams': function(params) {
+      L.extend(this.wmsParams, params);
+      this.update();
+  },
+
+  'getAttribution': function() {
+      return this.options.attribution;
+  },
+
+  'onAdd': function() {
+      this.update();
+  },
+
+  'onRemove': function(map) {
+      if (this._currentOverlay) {
+          map.removeLayer(this._currentOverlay);
+          delete this._currentOverlay;
+      }
+      if (this._currentUrl) {
+          delete this._currentUrl;
+      }
+  },
+
+  'getEvents': function() {
+      return {
+          'moveend': this.update
+      };
+  },
+
+  'update': function() {
+      if (!this._map) {
+          return;
+      }
+      // Determine image URL and whether it has changed since last update
+      this.updateWmsParams();
+      var url = this.getImageUrl();
+      if (this._currentUrl == url) {
+          return;
+      }
+      this._currentUrl = url;
+
+      // Keep current image overlay in place until new one loads
+      // (inspired by esri.leaflet)
+      var bounds = this._map.getBounds();
+      var overlay = L.imageOverlay(url, bounds, {'opacity': 0});
+      overlay.addTo(this._map);
+      overlay.once('load', _swap, this);
+      function _swap() {
+          if (!this._map) {
+              return;
+          }
+          if (overlay._url != this._currentUrl) {
+              this._map.removeLayer(overlay);
+              return;
+          } else if (this._currentOverlay) {
+              this._map.removeLayer(this._currentOverlay);
+          }
+          this._currentOverlay = overlay;
+          overlay.setOpacity(
+              this.options.opacity ? this.options.opacity : 1
+          );
+          if (this.options.isBack === true) {
+              overlay.bringToBack();
+          }
+          if (this.options.isBack === false) {
+              overlay.bringToFront();
+          }
+      }
+      if ((this._map.getZoom() < this.options.minZoom) ||
+          (this._map.getZoom() > this.options.maxZoom)){
+          this._map.removeLayer(overlay);
+      }
+  },
+
+  'setOpacity': function(opacity) {
+       this.options.opacity = opacity;
+       if (this._currentOverlay) {
+           this._currentOverlay.setOpacity(opacity);
+       }
+  },
+
+  'bringToBack': function() {
+      this.options.isBack = true;
+      if (this._currentOverlay) {
+          this._currentOverlay.bringToBack();
+      }
+  },
+
+  'bringToFront': function() {
+      this.options.isBack = false;
+      if (this._currentOverlay) {
+          this._currentOverlay.bringToFront();
+      }
+  },
+
+  // See L.TileLayer.WMS: onAdd() & getTileUrl()
+  'updateWmsParams': function(map) {
+      if (!map) {
+          map = this._map;
+      }
+      // Compute WMS options
+      var bounds = map.getBounds();
+      var size = map.getSize();
+      var wmsVersion = parseFloat(this.wmsParams.version);
+      var crs = this.options.crs || map.options.crs;
+      var projectionKey = wmsVersion >= 1.3 ? 'crs' : 'srs';
+      var nw = crs.project(bounds.getNorthWest());
+      var se = crs.project(bounds.getSouthEast());
+
+      // Assemble WMS parameter string
+      var params = {
+          'width': size.x,
+          'height': size.y
+      };
+      params[projectionKey] = crs.code;
+      params.bbox = (
+          wmsVersion >= 1.3 && crs === L.CRS.EPSG4326 ?
+          [se.y, nw.x, nw.y, se.x] :
+          [nw.x, se.y, se.x, nw.y]
+      ).join(',');
+
+      L.extend(this.wmsParams, params);
+  },
+
+  'getImageUrl': function() {
+      var uppercase = this.options.uppercase || false;
+      var pstr = L.Util.getParamString(this.wmsParams, this._url, uppercase);
+      return this._url + pstr;
+  }
+});
+
+wms.overlay = function(url, options) {
+  return new wms.Overlay(url, options);
+};
+
+// Simple AJAX helper (since we can't assume jQuery etc. are present)
+function ajax(url, callback) {
+  var context = this,
+      request = new XMLHttpRequest();
+  request.onreadystatechange = change;
+  request.open('GET', url);
+  request.send();
+
+  function change() {
+      if (request.readyState === 4) {
+          if (request.status === 200) {
+              callback.call(context, request.responseText);
+          } else {
+              callback.call(context, "error");
+          }
+      }
+  }
+}
+
+return wms;
+
+}));
diff --git a/webapp/src/tsconfig.app.json b/webapp/src/tsconfig.app.json
index 727fe0a3..edeee7ac 100644
--- a/webapp/src/tsconfig.app.json
+++ b/webapp/src/tsconfig.app.json
@@ -3,10 +3,12 @@
   "compilerOptions": {
     "outDir": "../out-tsc/app",
     "baseUrl": "./",
+    "allowJs": true,
     "module": "es2015",
     "types": [
       "leaflet",
-      "leaflet.markercluster"
+      "leaflet.markercluster",
+      "proj4leaflet"
     ]
   },
   "exclude": [
-- 
GitLab