import debounce from 'lodash/debounce' ; import React from 'react' ; import mapState , { ActiveMarkers , MapInfo , } from 'src/components/map-utils/map-state' ; import { MARKER_TYPES } from 'src/data' ; import * as dataDriver from 'src/data/dataDriver' ; import { Filter , Page } from 'src/state' ; import { isDefined } from 'src/util' ; import styled , { LARGE_DEVICES } from '../styling' ; import AddInstructions from './add-information' ; import { AppContext } from './context' ; import { haversineDistance , insertMapIntoHtml } from './map-utils/google-maps' ; import infoWindowContent from './map-utils/info-window' ; import { debouncedUpdateQueryStringMapLocation } from './map-utils/query-string' ; type MarkerInfo = dataDriver . MarkerInfoType ; dataDriver . addStorageListener ( ) ; interface MarkersData { markersData : Map < string , MarkerIdAndInfo > ; } const MARKER_DATA_ID = 'id' ; const MARKER_DATA_CIRCLE = 'circle' ; const INITIAL_NUMBER_OF_RESULTS = 20 ; export interface MarkerIdAndInfo { id : string ; info : MarkerInfo ; } /**
 * Either the current or next set of results in the results pane
 */ export interface ResultsSet { /**
     * How were these results calculated? E.g. were they from a cluster, or the
     * current zoom for the whole map.
     */ context : { /**
         * The bounds of the map at the time the results were calculated
         */ bounds : google . maps . LatLngBounds | null ; } ; results : MarkerIdAndInfo [ ] ; /**
     * How many rows from the results should be shown in the pane?
     * (used to limit how many dom elements we have)
     */ showRows : number ; } const hasIntersection = ( set_ , array_ ) : boolean => array_ . some ( value => set_ . has ( value ) ) ; interface Props { className ? : string ; filter : Filter ; results : ResultsSet | null ; setResults : ( results : ResultsSet , openResults ? : boolean ) => void ; nextResults : ResultsSet | null ; setNextResults : ( nextResults : ResultsSet ) => void ; selectedResult : MarkerIdAndInfo | null ; setSelectedResult : ( selectedResult : MarkerIdAndInfo | null ) => void ; /**
     * Call this
     */ setUpdateResultsCallback : ( callback : ( ( ) => void ) | null ) => void ; page : Page ; setPage : ( page : Page ) => void ; resultsOpen : boolean ; } interface State { /**
     * Shall we display service areas?
     * Can be disabled when the device has decreased performance
     */ displayServiceAreaCircle : boolean ; } class MapComponent extends React . Component < Props , State > { private static getCenterLocation ( data ) { let location = null ; if ( data . longitude && data . latitude ) { location = { lat : data . latitude , lng : data . longitude , } ; } else { navigator . geolocation . getCurrentPosition ( position => { location = { lat : position . coords . latitude , lng : position . coords . longitude , } ; } , ( ) => { location = { lat : 21.7679 , lng : 78.8718 , } ; } ) ; } return location ; } private readonly data : MarkersData = { markersData : new Map ( ) , } ; private addInfoMapClickedListener : ( ( evt : google . maps . MouseEvent ) => void ) | null = null ; private infoWindow : google . maps . InfoWindow | null = null ; public constructor ( props ) { super ( props ) ; this . state = { displayServiceAreaCircle : true , } ; } public async componentDidMount ( ) { const { setUpdateResultsCallback } = this . props ; setUpdateResultsCallback ( this . updateResults ) ; dataDriver . addInformationListener ( this . updateClusterAndMarkersListener ) ; const result = await this . centerMap ( ) ; if ( ! result ) { return ; } const p1 = result . getBounds ( ) ?. getNorthEast ( ) ; const p2 = result . getBounds ( ) ?. getSouthWest ( ) ; const upperLeft = { lat : p1 ?. lat ( ) as number , lng : p2 ?. lng ( ) as number } ; const lowerRight = { lat : p2 ?. lat ( ) as number , lng : p1 ?. lng ( ) as number } ; dataDriver . loadData ( { upperLeft , lowerRight } ) ; } public componentDidUpdate ( prevProps ) { const { mapInfo } = mapState ( ) ; const { filter , results , nextResults , selectedResult } = this . props ; if ( mapInfo && ! filter . filterExecuted ) { this . updateMarkersVisibilityUsingFilter ( filter ) ; filter . filterExecuted = true ; mapInfo . currentFilter = filter ; } if ( nextResults && ! results ) { this . updateResults ( ) ; } if ( selectedResult !== prevProps . selectedResult ) { if ( mapInfo && selectedResult ) { mapInfo . map . panTo ( { lat : selectedResult . info . loc . latlng . latitude , lng : selectedResult . info . loc . latlng . longitude , } ) ; } this . updateInfoWindow ( ) ; } } public componentWillUnmount ( ) { const { setUpdateResultsCallback } = this . props ; setUpdateResultsCallback ( null ) ; dataDriver . removeInformationListener ( this . updateClusterAndMarkersListener ) ; } private centerMap = async ( ) => { let centeredMap = null ; const response = await fetch ( 'https://get.geojs.io/v1/ip/geo.json' ) ; const data = await response . json ( ) ; if ( ! data ) { return null ; } const coords = { latitude : parseFloat ( data . latitude ) , longitude : parseFloat ( data . longitude ) , } ; const location = MapComponent . getCenterLocation ( coords ) ; if ( ! location ) { return null ; } const { mapInfo } = mapState ( ) ; if ( ! mapInfo ) { return null ; } centeredMap = mapInfo . map ; mapInfo . map . setCenter ( location ) ; mapInfo . map . setZoom ( 10 ) ; mapState ( ) . updateResultsOnNextBoundsChange = true ; return centeredMap ; } ; private isMatch = ( filter , info ) : boolean => { let validTypes = true ; if ( filter . markerTypes && filter . markerTypes . size > 0 && ( typeof info . info ?. type ?. type === 'undefined' || ! filter . markerTypes . has ( info . info . type . type ) ) ) { validTypes = false ; } let validServices = true ; if ( filter . services && filter . services . size > 0 && ( typeof info . info ?. type ?. services === 'undefined' || ! hasIntersection ( filter . services , info . info . type . services ) ) ) { validServices = false ; } const validVisibility = ! ! ( ! filter . hiddenMarkers || filter . hiddenMarkers === 'any' || ( filter . hiddenMarkers === 'hidden' && ! info . info . visible ) || ( filter . hiddenMarkers === 'visible' && info . info . visible ) ) ; const validText = ! ! ( ! filter . searchText || JSON . stringify ( info . info ) . toUpperCase ( ) . includes ( filter . searchText . toUpperCase ( ) ) ) ; return validTypes && validServices && validVisibility && validText ; } ; private updateMarkersVisibilityUsingFilter = ( filter ) => { const { mapInfo } = mapState ( ) ; if ( mapInfo ) { mapInfo . activeMarkers . markersData . forEach ( marker => { const info = this . getMarkerInfo ( marker ) ; if ( info ) { marker . setVisible ( this . isMatch ( filter , info ) ) ; } } ) ; mapState ( ) . updateResultsOnNextBoundsChange = true ; mapInfo . markerClusterer . repaint ( ) ; this . debouncedUpdateResultsBasedOnViewport ( ) ; } } ; private setAddInfoMapClickedListener = ( listener ) => { this . addInfoMapClickedListener = listener ; } ; /**
     * Return true if the usual behaviour for clicking should be supressed
     */ private mapClicked = ( evt ) : boolean => { if ( this . addInfoMapClickedListener ) { this . addInfoMapClickedListener ( evt ) ; return true ; } return false ; } ; private getMarkerInfo = ( marker ) : MarkerIdAndInfo | null => { const id = marker . get ( 'id' ) ; const info = this . data . markersData . get ( id ) ; return info || null ; } ; private createMarker = ( activeMarkers , id , info ) => { const marker = new window . google . maps . Marker ( { position : { lat : info . loc . latlng . latitude , lng : info . loc . latlng . longitude , } , title : info . contentTitle , } ) ; marker . set ( MARKER_DATA_ID , id ) ; activeMarkers . markersData . set ( id , marker ) ; marker . addListener ( 'click' , event => { const { setSelectedResult } = this . props ; if ( ! this . mapClicked ( event ) ) { const i = this . getMarkerInfo ( marker ) ; if ( i ) { setSelectedResult ( i ) ; } } } ) ; return marker ; } ; param update private updateClusterAndMarkersListener : dataDriver . MarkersListener = update => { this . data . markersData = new Map ( ) ; const allMarkers = update . initialMarkers ; for ( const marker of update . detailMarkers . entries ( ) ) { const id = marker [ 0 ] ; const markerInfo = marker [ 1 ] ; allMarkers . set ( id , markerInfo ) ; } for ( const entry of allMarkers . entries ( ) ) { this . data . markersData . set ( entry [ 0 ] , { id : entry [ 0 ] , info : entry [ 1 ] , } ) ; } const { mapInfo } = mapState ( ) ; if ( mapInfo ) { const newMarkers = [ ] ; for ( const [ id , info ] of allMarkers ) { const marker = mapInfo . activeMarkers . markersData . get ( id ) ; if ( marker ) { marker . setPosition ( { lat : info . loc . latlng . latitude , lng : info . loc . latlng . longitude , } ) ; marker . setTitle ( info . contentTitle ) ; } else { newMarkers . push ( this . createMarker ( mapInfo . activeMarkers , id , info ) ) ; } } mapInfo . markerClusterer . addMarkers ( newMarkers , true ) ; const removedMarkers = [ ] ; for ( const [ id , marker ] of mapInfo . activeMarkers . markersData . entries ( ) ) { if ( ! update . initialMarkers . has ( id ) && ! update . detailMarkers . has ( id ) ) { removedMarkers . push ( marker ) ; mapInfo . activeMarkers . markersData . delete ( id ) ; } } mapInfo . markerClusterer . removeMarkers ( removedMarkers , true ) ; this . updateMarkersVisibilityUsingFilter ( mapInfo . currentFilter ) ; } } ; /**
     * After 50 milliseconds, asynchronously populate nextResults based on markers
     * within bounds and set results to same value
     */ private debouncedUpdateResultsBasedOnViewport = debounce ( ( ) => { const { mapInfo } = mapState ( ) ; if ( mapInfo ) { const bounds = mapInfo . map . getBounds ( ) || null ; const nextResults = { context : { bounds , } , results : [ ] , showRows : INITIAL_NUMBER_OF_RESULTS , } ; for ( const markerIdAndInfo of this . data . markersData . values ( ) ) { if ( ! bounds || bounds . contains ( { lat : markerIdAndInfo . info . loc . latlng . latitude , lng : markerIdAndInfo . info . loc . latlng . longitude , } ) ) { const marker = mapInfo . activeMarkers . markersData . get ( markerIdAndInfo . id ) ; if ( marker && marker . getVisible ( ) ) { nextResults . results . push ( markerIdAndInfo ) ; } } } const { results , setNextResults , resultsOpen , selectedResult , setResults , } = this . props ; setNextResults ( nextResults ) ; if ( mapState ( ) . updateResultsOnNextBoundsChange || ( nextResults . context . bounds && results ?. context . bounds ?. equals ( nextResults . context . bounds ) ) || ( ! resultsOpen && ! selectedResult ) ) { mapState ( ) . updateResultsOnNextBoundsChange = false ; setResults ( nextResults , false ) ; } } } , 50 ) ; private insertMapAndMarkersIntoHtml = ( ref ) => { const { filter } = this . props ; const map = insertMapIntoHtml ( ref ) ; const activeMarkers = { markersData : new Map ( ) , } ; const data = this . data . markersData ; for ( const [ id , info ] of data ) { this . createMarker ( activeMarkers , id , info . info ) ; } const allMarkers = [ ... activeMarkers . markersData . values ( ) ] ; const markerClusterer = new MarkerClusterer ( map , allMarkers , { imagePath : 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m' , ignoreHidden : true , zoomOnClick : false , minimumClusterSize : 6 , } ) ; const m = { map , activeMarkers , currentFilter : filter , markerClusterer , } ; mapState ( ) . mapInfo = m ; this . updateMarkersVisibilityUsingFilter ( filter ) ; map . addListener ( 'bounds_changed' , ( ) => { if ( 'replaceState' in window . history ) { debouncedUpdateQueryStringMapLocation ( map ) ; } this . debouncedUpdateResultsBasedOnViewport ( ) ; } ) ; const drawMarkerServiceArea = ( marker ) => { const info = this . getMarkerInfo ( marker ) ; if ( ! info ) { return ; } const markerTypeInfo = MARKER_TYPES [ info . info . type . type ] ; const color = markerTypeInfo ? markerTypeInfo . color : MARKER_TYPES . notFound . color ; const mapBoundingBox = map . getBounds ( ) ; if ( mapBoundingBox ) { const topRight = mapBoundingBox . getNorthEast ( ) ; const bottomLeft = mapBoundingBox . getSouthWest ( ) ; const markerPosition = marker . getPosition ( ) ; const radius = info . info . loc . serviceRadius ; if ( ! radius ) { return ; } if ( markerPosition ) { const distanceToTopRight = haversineDistance ( markerPosition , topRight ) ; const distanceToBottomLeft = haversineDistance ( markerPosition , bottomLeft ) ; let circle = marker . get ( MARKER_DATA_CIRCLE ) ; if ( distanceToBottomLeft > radius || distanceToTopRight > radius ) { if ( ! circle ) { circle = new window . google . maps . Circle ( { strokeColor : color , strokeOpacity : 0.3 , strokeWeight : 1 , fillColor : color , fillOpacity : 0.15 , map , center : marker . getPosition ( ) || new google . maps . LatLng ( 90 , 90 ) , radius , clickable : false , } ) ; marker . set ( MARKER_DATA_CIRCLE , circle ) ; } circle . setVisible ( true ) ; } else if ( circle ) { circle . setVisible ( false ) ; } } } } ; markerClusterer . addListener ( 'click' , ( cluster ) => { const { setResults , setSelectedResult } = this . props ; setSelectedResult ( null ) ; setResults ( { context : { bounds : map . getBounds ( ) || null , } , results : cluster . getMarkers ( ) . map ( marker => this . getMarkerInfo ( marker ) ) . filter ( isDefined ) , showRows : INITIAL_NUMBER_OF_RESULTS , } , true ) ; } ) ; markerClusterer . addListener ( 'clusteringend' , ( newClusterParent ) => { const { displayServiceAreaCircle } = this . state ; m . clustering = { clusterMarkers : new Map ( ) , } ; const markersWithAreaDrawn = new Set < google . maps . Marker > ( ) ; for ( const cluster of newClusterParent . getClusters ( ) ) { let maxMarker = null ; const center = cluster . getCenter ( ) ; const clusterMarkers = cluster . getMarkers ( ) ; for ( const marker of clusterMarkers ) { const info = this . getMarkerInfo ( marker ) ; if ( info ) { if ( info . info . loc . serviceRadius && ( ! maxMarker || maxMarker . serviceRadius < info . info . loc . serviceRadius ) ) { maxMarker = { marker , serviceRadius : info . info . loc . serviceRadius , } ; } if ( clusterMarkers . length > 1 ) { m . clustering . clusterMarkers . set ( marker , center ) ; } } } if ( displayServiceAreaCircle && maxMarker ) { markersWithAreaDrawn . add ( maxMarker . marker ) ; drawMarkerServiceArea ( maxMarker . marker ) ; } } if ( displayServiceAreaCircle ) { m . activeMarkers . markersData . forEach ( marker => { if ( ! markersWithAreaDrawn . has ( marker ) ) { const circle = marker . get ( MARKER_DATA_CIRCLE ) ; if ( circle ) { circle . setVisible ( false ) ; } } } ) ; } this . updateInfoWindow ( ) ; } ) ; } ; private updateResults = ( ) => { const { mapInfo } = mapState ( ) ; const { results , nextResults , setResults } = this . props ; if ( mapInfo && nextResults && results !== nextResults ) { setResults ( nextResults , false ) ; } } ; /**
     * Open the tooltip for the currently selected marker, or close it if none is
     * selected. And return the coordinates that were used to place the tooltip.
     */ private updateInfoWindow = ( ) : google . maps . LatLng | undefined => { const { mapInfo } = mapState ( ) ; const { selectedResult , setSelectedResult } = this . props ; if ( ! mapInfo ) { return ; } const marker = selectedResult && mapInfo . activeMarkers . markersData . get ( selectedResult . id ) ; if ( selectedResult && marker ) { const clusterCenter = mapInfo . clustering ?. clusterMarkers . get ( marker ) ; const contentString = infoWindowContent ( selectedResult . info ) ; if ( ! this . infoWindow ) { this . infoWindow = new window . google . maps . InfoWindow ( { content : contentString , disableAutoPan : true , } ) ; this . infoWindow . addListener ( 'closeclick' , ( ) => setSelectedResult ( null ) ) ; } this . infoWindow . setContent ( contentString ) ; if ( clusterCenter ) { this . infoWindow . open ( mapInfo . map ) ; this . infoWindow . setPosition ( clusterCenter ) ; return clusterCenter ; } this . infoWindow . open ( mapInfo . map , marker ) ; return marker . getPosition ( ) || undefined ; } if ( this . infoWindow ) { this . infoWindow . close ( ) ; } } ; public render ( ) { const { mapInfo } = mapState ( ) ; const { className , page , setPage } = this . props ; return ( < AppContext . Consumer > { ( { lang } ) => ( < div className = { className || 'undefined' } > < div className = "map" ref = { this . insertMapAndMarkersIntoHtml } / > { page . page === 'add-information' && ( < AddInstructions lang = { lang } map = { ( mapInfo && mapInfo . map ) || null } addInfoStep = { page . step } setPage = { setPage } setAddInfoMapClickedListener = { this . setAddInfoMapClickedListener } / > ) } < /div>)} < /AppContext.Consumer>); } } export default styled ( MapComponent ) `
  height: 100%;
  position: relative;

  > .map {
    height: 100%;
  }

  > .search {
    position: absolute;
    max-width: 500px;
    top: ${ p => p . theme . spacingPx }px;
    left: ${ p => p . theme . spacingPx }px;
    right: 40px;

    ${ LARGE_DEVICES } {
      top: ${ p => p . theme . spacingPx + p . theme . secondaryHeaderSizePx }px;
    }
  }
` ;