Add searchbar to NetworkMap
[ccsdk/features.git] / sdnr / wt / odlux / apps / networkMapApp / src / components / map.tsx
1 /**
2  * ============LICENSE_START========================================================================
3  * ONAP : ccsdk feature sdnr wt odlux
4  * =================================================================================================
5  * Copyright (C) 2020 highstreet technologies GmbH Intellectual Property. All rights reserved.
6  * =================================================================================================
7  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
8  * in compliance with the License. You may obtain a copy of the License at
9  *
10  * http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software distributed under the License
13  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
14  * or implied. See the License for the specific language governing permissions and limitations under
15  * the License.
16  * ============LICENSE_END==========================================================================
17  */
18
19 import * as React from 'react'
20 import * as mapboxgl from 'mapbox-gl';
21 import InfoIcon from '@material-ui/icons/Info';
22 import { RouteComponentProps, withRouter } from 'react-router-dom';
23
24
25 import { site } from '../model/site';
26 import { SelectSiteAction, ClearHistoryAction, SelectLinkAction } from '../actions/detailsAction';
27 import { OSM_STYLE, URL_API, URL_BASEPATH, URL_TILE_API } from '../config';
28 import { link } from '../model/link';
29 import MapPopup from './mapPopup';
30 import { SetPopupPositionAction, SelectMultipleLinksAction, SelectMultipleSitesAction } from '../actions/popupActions';
31 import { Feature } from '../model/Feature';
32 import { HighlightLinkAction, HighlightSiteAction, SetCoordinatesAction, SetStatistics } from '../actions/mapActions';
33 import { addDistance, getUniqueFeatures } from '../utils/mapUtils';
34 import { location } from '../handlers/mapReducer'
35 import { Typography, Paper, Tooltip } from '@material-ui/core';
36 import { elementCount } from '../model/count';
37 import lamp from '../../icons/lamp.png';
38 import apartment from '../../icons/apartment.png';
39 import datacenter from '../../icons/datacenter.png';
40 import { IApplicationStoreState } from '../../../../framework/src/store/applicationStore';
41 import connect, { IDispatcher, Connect } from '../../../../framework/src/flux/connect';
42 import SearchBar from './searchBar';
43 import { verifyResponse, IsTileServerReachableAction, handleConnectionError } from '../actions/connectivityAction';
44 import ConnectionInfo from './connectionInfo'
45 import { ApplicationStore } from '../../../../framework/src/store/applicationStore';
46 import { showIconLayers, addBaseLayers, swapLayersBack } from '../utils/mapLayers';
47
48
49
50
51
52 type coordinates = { lat: number, lon: number, zoom: number }
53
54 let alarmElements: Feature[] = [];
55 let map: mapboxgl.Map;
56 let isLoadingInProgress = false;
57 let notLoadedBoundingBoxes: mapboxgl.LngLatBounds[] = [];
58
59 let lastBoundingBox: mapboxgl.LngLatBounds | null = null;
60 let myRef = React.createRef<HTMLDivElement>();
61
62
63 class Map extends React.Component<mapProps, { isPopupOpen: boolean }> {
64
65     constructor(props: mapProps) {
66         super(props);
67         //any state stuff
68         this.state = { isPopupOpen: false }
69
70     }
71
72     componentDidMount() {
73
74         window.addEventListener("menu-resized", this.handleResize);
75
76         fetch(URL_TILE_API + '/10/0/0.png')
77             .then(res => {
78                 if (res.ok) {
79                     this.setupMap();
80                 } else {
81                     this.props.setTileServerLoaded(false);
82                     console.error("tileserver " + URL_TILE_API + "can't be reached.")
83                 }
84             })
85             .catch(err => {
86                 this.props.setTileServerLoaded(false);
87                 console.error("tileserver " + URL_TILE_API + "can't be reached.")
88             });
89
90         fetch(URL_API + "/info")
91             .then(result => verifyResponse(result))
92             .catch(error => this.props.handleConnectionError(error));
93     }
94
95     setupMap = () => {
96
97         let lat = this.props.lat;
98         let lon = this.props.lon;
99         let zoom = this.props.zoom;
100
101         const coordinates = this.extractCoordinatesFromUrl();
102         // override lat/lon/zoom with coordinates from url, if available
103         if (this.areCoordinatesValid(coordinates)) {
104             lat = coordinates.lat;
105             lon = coordinates.lon;
106             zoom = !Number.isNaN(coordinates.zoom) ? coordinates.zoom : zoom;
107         }
108
109         map = new mapboxgl.Map({
110             container: myRef.current!,
111             style: OSM_STYLE as any,
112             center: [lon, lat],
113             zoom: zoom,
114             accessToken: ''
115         });
116
117         map.on('load', (ev) => {
118
119             addBaseLayers(map, this.props.selectedSite, this.props.selectedLink);
120             map.loadImage(
121                 lamp,
122                 function (error: any, image: any) {
123                     if (error) throw error;
124                     map.addImage('lamp', image);
125                 });
126
127             map.loadImage(
128                 datacenter,
129                 function (error: any, image: any) {
130                     if (error) throw error;
131                     map.addImage('data-center', image);
132                 });
133
134             map.loadImage(
135                 apartment,
136                 function (error: any, image: any) {
137                     if (error) throw error;
138                     map.addImage('house', image);
139                 });
140
141             const boundingBox = map.getBounds();
142
143
144             fetch(`${URL_API}/links/geoJson/${boundingBox.getWest()},${boundingBox.getSouth()},${boundingBox.getEast()},${boundingBox.getNorth()}`)
145                 .then(result => verifyResponse(result))
146                 .then(result => result.json())
147                 .then(features => {
148                     if (map.getLayer('lines')) {
149                         (map.getSource('lines') as mapboxgl.GeoJSONSource).setData(features);
150                     }
151                 })
152                 .catch(error => this.props.handleConnectionError(error));
153
154
155             fetch(`${URL_API}/sites/geoJson/${boundingBox.getWest()},${boundingBox.getSouth()},${boundingBox.getEast()},${boundingBox.getNorth()}`)
156                 .then(result => verifyResponse(result))
157                 .then(result => result.json())
158                 .then(features => {
159                     if (map.getLayer('points')) {
160                         (map.getSource('points') as mapboxgl.GeoJSONSource).setData(features);
161                     }
162                 })
163                 .catch(error => this.props.handleConnectionError(error));;
164
165         });
166
167         map.on('click', (e: any) => {
168
169             if (map.getLayer('points')) { // data is shown as points
170
171                 var clickedLines = getUniqueFeatures(map.queryRenderedFeatures([[e.point.x - 5, e.point.y - 5],
172                 [e.point.x + 5, e.point.y + 5]], {
173                     layers: ['lines']
174                 }), "id");
175
176                 const clickedPoints = getUniqueFeatures(map.queryRenderedFeatures(e.point, { layers: ['points'] }), "id");
177                 const alarmedSites = getUniqueFeatures(map.queryRenderedFeatures(e.point, { layers: ['alarmedPoints'] }), "id");
178
179                 if (clickedPoints.length != 0) {
180
181
182                     if (alarmedSites.length > 0) {
183                         alarmedSites.forEach(alarm => {
184                             const index = clickedPoints.findIndex(item => item.properties!.id === alarm.properties!.id);
185                             console.log(index);
186
187                             if (index !== -1) {
188                                 clickedPoints[index].properties!.alarmed = true;
189                                 clickedPoints[index].properties!.type = "alarmed";
190                             }
191                         });
192                         console.log(clickedPoints);
193                     }
194
195                     this.showSitePopup(clickedPoints, e.point.x, e.point.y);
196                 } else if (clickedLines.length != 0) {
197                     this.showLinkPopup(clickedLines, e.point.x, e.point.y);
198                 }
199
200
201             } else { // data is shown as icons
202
203                 const clickedLamps = getUniqueFeatures(map.queryRenderedFeatures(e.point, { layers: ['point-lamps'] }), "id");
204                 const buildings = getUniqueFeatures(map.queryRenderedFeatures(e.point, { layers: ['point-building'] }), "id");
205                 const houses = getUniqueFeatures(map.queryRenderedFeatures(e.point, { layers: ['point-data-center'] }), "id");
206
207                 const combinedFeatures = [...clickedLamps, ...buildings, ...houses];
208
209                 const clickedLines = getUniqueFeatures(map.queryRenderedFeatures([[e.point.x - 5, e.point.y - 5],
210                 [e.point.x + 5, e.point.y + 5]], {
211                     layers: ['lines']
212                 }), "id");
213
214                 if (combinedFeatures.length > 0)
215                     this.showSitePopup(combinedFeatures, e.point.x, e.point.y);
216                 else if (clickedLines.length != 0) {
217                     this.showLinkPopup(clickedLines, e.point.x, e.point.y);
218                 }
219             }
220
221         });
222
223         map.on('moveend', () => {
224
225             const mapZoom = Number(map.getZoom().toFixed(2));
226             const lat = Number(map.getCenter().lat.toFixed(4));
227             const lon = Number(map.getCenter().lng.toFixed(4));
228
229
230             if (this.props.lat !== lat || this.props.lon !== lon || this.props.zoom !== mapZoom) {
231                 this.props.updateMapPosition(lat, lon, mapZoom)
232             }
233
234             const currentUrl = window.location.href;
235             const parts = currentUrl.split(URL_BASEPATH);
236             const detailsPath = parts[1].split("/details/");
237
238             if (detailsPath[1] !== undefined && detailsPath[1].length > 0) {
239                 this.props.history.replace(`/${URL_BASEPATH}/${map.getCenter().lat.toFixed(4)},${map.getCenter().lng.toFixed(4)},${mapZoom.toFixed(2)}/details/${detailsPath[1]}`)
240             }
241             else {
242                 this.props.history.replace(`/${URL_BASEPATH}/${map.getCenter().lat.toFixed(4)},${map.getCenter().lng.toFixed(4)},${mapZoom.toFixed(2)}`)
243             }
244
245             const boundingBox = map.getBounds();
246
247             showIconLayers(map, this.props.showIcons, this.props.selectedSite?.properties.id);
248
249             fetch(`${URL_API}/info/count/${boundingBox.getWest()},${boundingBox.getSouth()},${boundingBox.getEast()},${boundingBox.getNorth()}`)
250                 .then(result => verifyResponse(result))
251                 .then(res => res.json())
252                 .then(result => {
253                     console.log(result);
254                     if (result.links !== this.props.linkCount || result.sites !== this.props.siteCount) {
255                         this.props.setStatistics(result.links, result.sites);
256                     }
257                 })
258                 .catch(error => this.props.handleConnectionError(error));;
259         })
260
261         map.on('move', () => {
262             const mapZoom = map.getZoom();
263
264             const boundingBox = map.getBounds();
265
266             this.loadNetworkData(boundingBox);
267             if (mapZoom > 9) {
268
269                 if (map.getLayer('points')) {
270                     map.setLayoutProperty('selectedPoints', 'visibility', 'visible');
271                     map.setPaintProperty('points', 'circle-radius', 7);
272                 }
273             } else {
274
275                 // reduce size of points / lines if zoomed out
276                 map.setPaintProperty('points', 'circle-radius', 2);
277                 map.setLayoutProperty('selectedPoints', 'visibility', 'none');
278
279                 if (mapZoom <= 4) {
280                     map.setPaintProperty('lines', 'line-width', 1);
281                 } else {
282                     map.setPaintProperty('lines', 'line-width', 2);
283                 }
284             }
285         });
286     }
287
288     componentDidUpdate(prevProps: mapProps, prevState: {}) {
289
290         if (map !== undefined) {
291             if (prevProps.selectedSite?.properties.id !== this.props.selectedSite?.properties.id) {
292
293                 if (this.props.selectedSite != null) {
294                     if (map.getSource("selectedLine") !== undefined) {
295                         (map.getSource("selectedLine") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [] });
296                         (map.getSource("selectedPoints") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [this.props.selectedSite] });
297                     }
298
299
300                     if (map.getLayer('point-lamps') !== undefined) {
301
302                         map.setFilter('point-lamps', ['==', 'type', 'street lamp']);
303                         map.setFilter('point-data-center', ['==', 'type', 'data center']);
304                         map.setFilter('point-building', ['==', 'type', 'high rise building'])
305
306                         if (this.props.selectedSite?.properties.type !== undefined) {
307                             switch (this.props.selectedSite?.properties.type) {
308                                 case 'street lamp':
309                                     map.setFilter('point-lamps', ["all", ['==', 'type', 'street lamp'], ['!=', 'id', this.props.selectedSite.properties.id]]);
310                                     break;
311                                 case 'data center':
312                                     map.setFilter('point-data-center', ["all", ['==', 'type', 'data center'], ['!=', 'id', this.props.selectedSite.properties.id]]);
313                                     break;
314                                 case 'high rise building':
315                                     map.setFilter('point-building', ["all", ['==', 'type', 'high rise building'], ['!=', 'id', this.props.selectedSite.properties.id]])
316
317                                     break;
318                             }
319                         }
320                     }
321
322
323                 }
324                 else 
325                 {
326                     if (map.getSource("selectedPoints") !== undefined)
327                         (map.getSource("selectedPoints") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [] });
328
329                 }
330             }
331
332             if (prevProps.selectedLink !== this.props.selectedLink) {
333                 if (this.props.selectedLink != null) {
334
335                     if (map.getLayer('point-lamps') !== undefined) {
336                         map.setFilter('point-lamps', ['==', 'type', 'street lamp']);
337                         map.setFilter('point-data-center', ['==', 'type', 'data center']);
338                         map.setFilter('point-building', ['==', 'type', 'high rise building']);
339                     }
340
341                     if (map.getSource("selectedLine") !== undefined) {
342                         (map.getSource("selectedPoints") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [] });
343                         (map.getSource("selectedLine") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [this.props.selectedLink] });
344                     }
345                 }
346                 else 
347                 {
348                     if (map.getSource("selectedLine") !== undefined)
349                         (map.getSource("selectedLine") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [] });
350                 }
351             }
352
353             if (prevProps.location.pathname !== this.props.location.pathname) {
354                 if (map) {
355                     const coordinates = this.extractCoordinatesFromUrl();
356                     this.moveMapToCoordinates(coordinates);
357                 }
358             }
359
360             if (prevProps.alarmlement !== this.props.alarmlement) {
361                 if (this.props.alarmlement !== null && !alarmElements.includes(this.props.alarmlement)) {
362                     if (map.getSource("alarmedPoints"))
363                         (map.getSource("alarmedPoints") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: alarmElements });
364                     alarmElements.push(this.props.alarmlement)
365                 }
366             }
367
368             if (prevProps.showIcons !== this.props.showIcons) {
369                 if (map && map.getZoom() > 11) {
370                     console.log(this.props.showIcons);
371                     showIconLayers(map, this.props.showIcons, this.props.selectedSite?.properties.id);
372                 }
373             }
374
375             if (prevProps.zoomToElement !== this.props.zoomToElement) {
376                 if (this.props.zoomToElement !== null) {
377                     const currentZoom = map?.getZoom();
378
379                     map.flyTo({
380                         center: [
381                             this.props.zoomToElement.lon,
382                             this.props.zoomToElement.lat
383                         ], zoom: currentZoom < 10 ? 10 : currentZoom,
384                         essential: true
385                     });
386                 }
387             }
388         }
389     }
390
391     handleResize = () => {
392         if (map) {
393             // wait a moment until resizing actually happened
394             window.setTimeout(() => map.resize(), 500);
395         }
396     }
397
398     extractCoordinatesFromUrl = (): coordinates => {
399         const currentUrl = window.location.href;
400         const mainPathParts = currentUrl.split(URL_BASEPATH);
401         const coordinatePathPart = mainPathParts[1].split("/details/"); // split by details if present
402         const allCoordinates = coordinatePathPart[0].replace("/", "");
403         const coordinates = allCoordinates.split(",");
404         return { lat: Number(coordinates[0]), lon: Number(coordinates[1]), zoom: Number(coordinates[2]) }
405     }
406
407     areCoordinatesValid = (coordinates: coordinates) => {
408
409         if ((!Number.isNaN(coordinates.lat)) && (!Number.isNaN(coordinates.lon))) {
410             return true;
411         } else {
412             return false;
413         }
414     }
415
416     moveMapToCoordinates = (coordinates: coordinates) => {
417
418         if (this.areCoordinatesValid(coordinates)) {
419             let zoom = -1;
420
421             if (!Number.isNaN(coordinates.zoom)) {
422                 zoom = coordinates.zoom;
423             }
424
425             map.flyTo({
426                 center: [
427                     coordinates.lon,
428                     coordinates.lat
429                 ], zoom: zoom !== -1 ? zoom : this.props.zoom,
430                 essential: true
431             })
432         }
433     }
434
435
436     //TODO: how to handle if too much data gets loaded? (1 mio points...?)
437     // data might have gotten collected, reload if necessary!
438     //always save count, if count and current view count differ -> reload last boundingbox
439     loadNetworkData = async (bbox: mapboxgl.LngLatBounds) => {
440         if (!isLoadingInProgress) { // only load data if loading not in progress
441             isLoadingInProgress = true;
442
443             if (lastBoundingBox == null) {
444                 lastBoundingBox = bbox;
445                 await this.draw('lines', `${URL_API}/links/geoJson/${lastBoundingBox.getWest()},${lastBoundingBox.getSouth()},${lastBoundingBox.getEast()},${lastBoundingBox.getNorth()}`);
446                 await this.draw('points', `${URL_API}/sites/geoJson/${lastBoundingBox.getWest()},${lastBoundingBox.getSouth()},${lastBoundingBox.getEast()},${lastBoundingBox.getNorth()}`);
447             } else {
448
449                 // new bbox is bigger than old one
450                 if (bbox.contains(lastBoundingBox.getNorthEast()) && bbox.contains(lastBoundingBox.getSouthWest()) && lastBoundingBox !== bbox) {  //if new bb is bigger than old one
451
452                     lastBoundingBox = bbox;
453
454                     const distance = map.getCenter().distanceTo(bbox.getNorthEast()); // radius of visible area (center -> corner) (in meters)
455
456                     //calculate new boundingBox
457                     const increasedBoundingBox = addDistance(bbox.getSouth(), bbox.getWest(), bbox.getNorth(), bbox.getEast(), (distance / 1000) / 2)
458
459                     await this.draw('lines', `${URL_API}/links/geoJson/${increasedBoundingBox.west},${increasedBoundingBox.south},${increasedBoundingBox.east},${increasedBoundingBox.north}`);
460                     await this.draw('points', `${URL_API}/sites/geoJson/${increasedBoundingBox.west},${increasedBoundingBox.south},${increasedBoundingBox.east},${increasedBoundingBox.north}`);
461                     console.log("bbox is bigger");
462
463                 } else if (lastBoundingBox.contains(bbox.getNorthEast()) && lastBoundingBox.contains(bbox.getSouthWest())) { // last one contains new one
464                     // bbox is contained in last one, do nothing
465                     isLoadingInProgress = false;
466
467                 } else { // bbox is not fully contained in old one, extend 
468
469                     lastBoundingBox.extend(bbox);
470
471                     await this.draw('lines', `${URL_API}/links/geoJson/${lastBoundingBox.getWest()},${lastBoundingBox.getSouth()},${lastBoundingBox.getEast()},${lastBoundingBox.getNorth()}`);
472                     await this.draw('points', `${URL_API}/sites/geoJson/${lastBoundingBox.getWest()},${lastBoundingBox.getSouth()},${lastBoundingBox.getEast()},${lastBoundingBox.getNorth()}`);
473                 }
474
475             }
476
477
478             if (notLoadedBoundingBoxes.length > 0) { // load last not loaded boundingbox
479                 this.loadNetworkData(notLoadedBoundingBoxes.pop()!)
480                 notLoadedBoundingBoxes = [];
481             }
482
483         } else {
484             notLoadedBoundingBoxes.push(bbox);
485         }
486     }
487
488     showSitePopup = (sites: mapboxgl.MapboxGeoJSONFeature[], top: number, left: number) => {
489         if (sites.length > 1) {
490             const ids = sites.map(feature => feature.properties!.id);
491
492             this.props.setPopupPosition(top, left);
493             this.props.selectMultipleSites(ids);
494             this.setState({ isPopupOpen: true });
495
496         } else {
497             const id = sites[0].properties!.id;
498
499             fetch(`${URL_API}/site/${id}`)
500                 .then(result => verifyResponse(result))
501                 .then(res => res.json() as Promise<site>)
502                 .then(result => {
503                     this.props.selectSite(result);
504                     this.props.highlightSite(result);
505                     this.props.clearDetailsHistory();
506                 })
507                 .catch(error => this.props.handleConnectionError(error));;
508         }
509
510     }
511
512
513
514     showLinkPopup = (links: mapboxgl.MapboxGeoJSONFeature[], top: number, left: number) => {
515
516         if (links.length > 1) {
517
518             const ids = links.map(feature => feature.properties!.id as string);
519
520             this.props.setPopupPosition(top, left);
521             this.props.selectMultipleLinks(ids);
522             this.setState({ isPopupOpen: true });
523
524         } else {
525             var id = links[0].properties!.id;
526
527             fetch(`${URL_API}/link/${id}`)
528                 .then(result => verifyResponse(result))
529                 .then(res => res.json() as Promise<link>)
530                 .then(result => {
531                     this.props.selectLink(result);
532                     this.props.highlightLink(result);
533
534                     this.props.clearDetailsHistory();
535                 })
536                 .catch(error => this.props.handleConnectionError(error));;
537         }
538     }
539
540     draw = async (layer: string, url: string) => {
541
542         fetch(url)
543             .then(result => verifyResponse(result))
544             .then(res => res.json())
545             .then(result => {
546                 isLoadingInProgress = false;
547                 if (map.getSource(layer)) {
548                     (map.getSource(layer) as mapboxgl.GeoJSONSource).setData(result);
549                 }
550             })
551             .catch(error => this.props.handleConnectionError(error));;
552     }
553
554     render() {
555
556         const reachabe = this.props.isTopoServerReachable && this.props.isTileServerReachable;
557
558         return <>
559
560             <div id="map" style={{ width: "70%", position: 'relative' }} ref={myRef} >
561                 {
562                     this.state.isPopupOpen &&
563                     <MapPopup onClose={() => { this.setState({ isPopupOpen: false }); }} />
564                 }
565                 <SearchBar />
566                 <ConnectionInfo />
567             </div>
568         </>
569     }
570
571 }
572
573 type mapProps = RouteComponentProps & Connect<typeof mapStateToProps, typeof mapDispatchToProps>;
574
575 const mapStateToProps = (state: IApplicationStoreState) => ({
576     selectedLink: state.network.map.selectedLink,
577     selectedSite: state.network.map.selectedSite,
578     zoomToElement: state.network.map.zoomToElement,
579     alarmlement: state.network.map.alarmlement,
580     lat: state.network.map.lat,
581     lon: state.network.map.lon,
582     zoom: state.network.map.zoom,
583     linkCount: state.network.map.statistics.links,
584     siteCount: state.network.map.statistics.sites,
585     isTopoServerReachable: state.network.connectivity.isToplogyServerAvailable,
586     isTileServerReachable: state.network.connectivity.isTileServerAvailable,
587     showIcons: state.network.map.allowIconSwitch
588
589
590
591 });
592
593 const mapDispatchToProps = (dispatcher: IDispatcher) => ({
594     selectSite: (site: site) => dispatcher.dispatch(new SelectSiteAction(site)),
595     selectLink: (link: link) => dispatcher.dispatch(new SelectLinkAction(link)),
596     clearDetailsHistory: () => dispatcher.dispatch(new ClearHistoryAction()),
597     selectMultipleLinks: (ids: string[]) => dispatcher.dispatch(new SelectMultipleLinksAction(ids)),
598     selectMultipleSites: (ids: string[]) => dispatcher.dispatch(new SelectMultipleSitesAction(ids)),
599     setPopupPosition: (x: number, y: number) => dispatcher.dispatch(new SetPopupPositionAction(x, y)),
600     highlightLink: (link: link) => dispatcher.dispatch(new HighlightLinkAction(link)),
601     highlightSite: (site: site) => dispatcher.dispatch(new HighlightSiteAction(site)),
602     updateMapPosition: (lat: number, lon: number, zoom: number) => dispatcher.dispatch(new SetCoordinatesAction(lat, lon, zoom)),
603     setStatistics: (linkCount: string, siteCount: string) => dispatcher.dispatch(new SetStatistics(siteCount, linkCount)),
604     setTileServerLoaded: (reachable: boolean) => dispatcher.dispatch(new IsTileServerReachableAction(reachable)),
605     handleConnectionError: (error: Error) => dispatcher.dispatch(handleConnectionError(error))
606 })
607
608 export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Map));