import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, Output, QueryList, Renderer2, ViewChild, ViewChildren, ViewEncapsulation, } from '@angular/core'; import { Subject, takeUntil } from 'rxjs'; // Modules import { AngularSvgIconModule } from 'angular-svg-icon'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { GoogleMap, GoogleMapsModule, MapAdvancedMarker, } from '@angular/google-maps'; // Components import { CaMapDropdownComponent } from '../ca-map-dropdown/ca-map-dropdown.component'; import { CaAppTooltipV2Component } from '../ca-app-tooltip-v2/ca-app-tooltip-v2.component'; // Enums import { GoogleMapEnum } from '../ca-map/enums/google-map.enum'; import { eMapMarkerString } from './enums'; import { eStringPlaceholder } from '../../enums'; // Models import { IMapMarkers, ICaMapProps, IMapOptions, IRoutePath, IMapBoundsZoom, IMapSelectedMarkerData, IMapAreaFilter, } from '../ca-map/models'; // Helpers import { MapHelper } from '../ca-map/utils/helpers/map.helper'; // Constants import { MapOptionsConstants, MapConstants } from './utils/constants'; // Services import { MapMarkerIconService } from './utils/services'; // Svg routes import { SharedSvgRoutes } from '../../utils/svg-routes'; @Component({ selector: 'app-ca-map', templateUrl: './ca-map.component.html', styleUrls: ['./ca-map.component.scss'], imports: [ // Modules GoogleMapsModule, CommonModule, AngularSvgIconModule, NgbTooltipModule, // Components CaMapDropdownComponent, CaAppTooltipV2Component, ], encapsulation: ViewEncapsulation.None, }) export class CaMapComponent { constructor( public cd: ChangeDetectorRef, private el: ElementRef, private renderer: Renderer2, // Services private markerIconService: MapMarkerIconService ) {} @ViewChild(GoogleMap, { static: false }) maps!: GoogleMap; @ViewChild('markerDropdown') markerDropdown!: ElementRef; @ViewChild('clusterDropdown') clusterDropdown!: ElementRef; @ViewChildren(MapAdvancedMarker) mapMarkers!: QueryList; @Input() set data(values: ICaMapProps) { this.handleMapData(values); } @Output() resetSelectedMarkerItem = new EventEmitter(); @Output() routingMarkerClick = new EventEmitter(); @Output() clusterMarkerClick = new EventEmitter(); @Output() clusterListScrollEvent = new EventEmitter(); @Output() getInfoWindowData = new EventEmitter(); @Output() boundsChanged = new EventEmitter(); @Output() openInMapEvent = new EventEmitter(); private destroy$ = new Subject(); public directionsRenderers: google.maps.DirectionsRenderer[] = []; public mapOptions: IMapOptions = MapOptionsConstants.DEFAULT_MAP_OPTIONS; public mapZoom: number = MapOptionsConstants.DEFAULT_MAP_ZOOM; public mapCenter: IRoutePath = MapOptionsConstants.DEFAULT_MAP_CENTER; public map: google.maps.Map | null = null; public mapStyleId: string = 'd681904a390a8402'; public mapData: ICaMapProps = {}; public mapScaleMilesLabel: number = 0; public mapScalePixelWidth: number = 0; public openedInfoWindowData: IMapMarkers | null = null; public selectedRoutingMarker: IMapMarkers | null = null; public areaFilterData: IMapAreaFilter | null = null; public routePathColors: string[] = []; public routePolylines: google.maps.PolylineOptions[] = []; public isMarkerDropdownOpen: boolean = false; public isClusterDropdownOpen: boolean = false; public isMapCenterSet: boolean = false; public openDropdownTimeout: ReturnType | number = 0; public selectRoutingTimeout: ReturnType | number = 0; public sharedSvgRoutes = SharedSvgRoutes; ngOnInit() { // Reset all icons to their initial state on page load this.resetMarkersIcons(); this.storeRoutePathColors(); } ngAfterViewInit() { this.maps.googleMap?.addListener('click', () => { this.closeInfoWindow(); }); } public onClusterMarkerClick(markerItem: IMapMarkers): void { const isInfoWindowOpened = this.checkInfoWindowPosition( markerItem, this.openedInfoWindowData ); if (!isInfoWindowOpened) this.clusterMarkerClick.emit(markerItem); else this.closeInfoWindow(); } public getMarkerInfoWindow(markerId: number): void { if (markerId) this.getInfoWindowData.emit(markerId); } public onMarkerClick(markerItem: IMapMarkers): void { const isInfoWindowOpened = this.checkInfoWindowPosition( markerItem, this.openedInfoWindowData ); if (!isInfoWindowOpened) this.getMarkerInfoWindow(markerItem.data.id); else this.closeInfoWindow(); } public onRoutingMarkerClick(markerItem: IMapMarkers): void { const isRoutingMarkerSelected = this.checkInfoWindowPosition( markerItem, this.selectedRoutingMarker ); if (!isRoutingMarkerSelected) { if (markerItem?.data?.id) this.routingMarkerClick.emit(markerItem?.data?.id); else this.handleRoutingMarkerSelect(markerItem); } else this.closeInfoWindow(); } public onMapReady(mapInstance: google.maps.Map): { fillColor?: string; strokeColor?: string; strokeWeight?: number; fillOpacity?: number; } | void { this.map = mapInstance; if (this.mapData?.darkMode) { const styledMapType = new google.maps.StyledMapType( MapConstants.GOOGLE_MAP_DARK_STYLES ); this.map!.mapTypes.set(GoogleMapEnum.DARK_MAP, styledMapType); this.map!.setMapTypeId(GoogleMapEnum.DARK_MAP); } if (this.mapData.stateBoundariesUrl) { const stateBoundariesUrl = this.mapData.stateBoundariesUrl.url; this.maps.googleMap?.data.loadGeoJson(stateBoundariesUrl); this.maps.googleMap?.data.setStyle((feature) => { const stateDensity = feature.getProperty('density'); const { fillColor, fillOpacity } = MapHelper.getFillColorForState( stateDensity as number, this.mapData.view || '' ); return { fillColor: fillColor, strokeColor: '#EEEEEE', strokeWeight: 0.6, fillOpacity: fillOpacity, }; }); } this.sendBoundsChangedEvent(); this.getMapIdleEvent(mapInstance); this.setMapStyles(); } public onClusterBackButtonClick(): void { this.resetSelectedMarkerItem.emit(true); } public checkInfoWindowPosition( markerItem: IMapMarkers, markerItem2?: IMapMarkers | null ): boolean { if (markerItem?.data?.id && markerItem2?.data?.id) return markerItem.data.id === markerItem2.data.id; return ( markerItem.position.lat === markerItem2?.position?.lat && markerItem.position.lng === markerItem2?.position?.lng ); } public onClusterListScrollToEnd(): void { if (this.openedInfoWindowData) this.clusterListScrollEvent.emit(this.openedInfoWindowData); } public onZoomChange(isMinusClick?: boolean): void { const currentZoomLevel = this.map?.getZoom(); if (currentZoomLevel) { const nextZoomLevel = isMinusClick ? currentZoomLevel - 1 : currentZoomLevel + 1; this.map?.setZoom(nextZoomLevel); } } public onOpenInMap(): void { this.openInMapEvent.emit(); } private calculateAndDisplayRoute(): void { if (this.mapData.routePaths) { // Clear previous route polylines this.routePolylines = []; this.mapData.routePaths!.forEach((route, index) => { if (route.decodedShape) { const polyLineCoordinates = route.decodedShape?.map( (item) => { const coordinates = { lat: item.latitude!, lng: item.longitude!, }; return coordinates; } ); const routePathDashedLine = [ { ...MapOptionsConstants.ROUTING_DASHED_LINE_ICON[0], icon: { ...MapOptionsConstants.ROUTING_DASHED_LINE_PATH, strokeColor: route.strokeColor, }, }, ]; const routePolyline: google.maps.PolylineOptions = { path: polyLineCoordinates, geodesic: true, strokeColor: route.strokeColor || GoogleMapEnum.STROKE_COLOR, strokeOpacity: !route.isDashed ? route.strokeOpacity || GoogleMapEnum.STROKE_OPACITY : 0, strokeWeight: route.strokeWeight || GoogleMapEnum.STROKE_WEIGHT, icons: route.isDashed ? (route.dashedLineStyle ?? routePathDashedLine) : null, clickable: false, zIndex: index, }; this.routePolylines.push(routePolyline); } }); } } private resetMarkersIcons(): void { this.mapData.markers?.map((marker: IMapMarkers, index: number) => { const markerIcon = this.el.nativeElement.querySelector( '#marker-' + marker.id ); if (markerIcon) this.renderer.removeClass( markerIcon, eMapMarkerString.SELECTED ); this.isMarkerDropdownOpen = false; return { ...marker, options: { zIndex: index + 1 }, }; }); this.mapData.clusterMarkers?.map( (clusterMarker: IMapMarkers, index: number) => { const markerIconId = eStringPlaceholder.HASH_SIGN + clusterMarker.content.id; const markerIcon = this.el.nativeElement.querySelector(markerIconId); if (markerIcon) this.renderer.removeClass( markerIcon, eMapMarkerString.SELECTED ); this.isClusterDropdownOpen = false; } ); this.mapData.routingMarkers?.map((routingMarker, index) => { const markerIcon = this.el.nativeElement.querySelector( eStringPlaceholder.HASH_SIGN + routingMarker.content.id ); if (markerIcon) this.renderer.removeClass( markerIcon, eMapMarkerString.SELECTED ); this.selectedRoutingMarker = null; }); this.routePolylines = this.routePolylines?.map((polyline, index) => { const pathStrokeColor = this.routePathColors[index]; if (polyline?.icons?.[0]?.icon) { const routePathDashedLine = [ { ...polyline.icons[0], icon: { ...polyline.icons[0].icon, strokeColor: pathStrokeColor, }, }, ]; return { ...polyline, icons: routePathDashedLine, zIndex: index, }; } return { ...polyline, strokeColor: pathStrokeColor, zIndex: index, }; }); this.cd.detectChanges(); } private setMapStyles(): void { const allMarkersCombined = [ ...(this.mapData.routingMarkers ?? []), ...(this.mapData.clusterMarkers ?? []), ...(this.mapData.markers ?? []), ]; if (!allMarkersCombined?.length || !this.map || this.isMapCenterSet) return; const mapCenterAndBounds = MapHelper.getMapCenterAndZoom(allMarkersCombined); if (mapCenterAndBounds.bounds) { this.map!.fitBounds(mapCenterAndBounds.bounds); } else { this.map!.panTo(mapCenterAndBounds.mapCenter); if (mapCenterAndBounds.mapZoom) this.map!.setZoom(mapCenterAndBounds.mapZoom); } this.isMapCenterSet = true; } private getMapIdleEvent(map: google.maps.Map): void { map.addListener('idle', () => { this.sendBoundsChangedEvent(); if (this.mapData.isZoomShown) this.getScaleDistance(); }); } private sendBoundsChangedEvent(): void { if (this.map) { const mapBounds = this.map.getBounds(); const mapZoom = this.map.getZoom(); if (mapBounds && mapZoom) { const boundsObject = { bounds: mapBounds, zoom: mapZoom }; this.boundsChanged.emit(boundsObject); } } } private openMarkerInfoWindow(markerItem: IMapSelectedMarkerData): void { const isInfoWindowOpened = this.checkInfoWindowPosition( markerItem, this.openedInfoWindowData ); const combinedMarkers = [ ...this.mapData.markers!, ...this.mapData.clusterMarkers!, ]; combinedMarkers.forEach((item: IMapMarkers, index: number) => { const isSelectedMarker = this.checkInfoWindowPosition( item, markerItem ); const isClusterMarker = item?.data?.count > 1; const markerIconId = eStringPlaceholder.HASH_SIGN + item.content.id; const dropdownElement = isClusterMarker ? this.clusterDropdown.nativeElement : this.markerDropdown.nativeElement; if (isSelectedMarker) { item.options = { zIndex: 999 }; if (isClusterMarker) this.isMarkerDropdownOpen = false; else this.isClusterDropdownOpen = false; clearTimeout(this.openDropdownTimeout); this.openDropdownTimeout = setTimeout(() => { const markerIcon = this.el.nativeElement.querySelector(markerIconId); if (!(markerIcon?.previousSibling === dropdownElement)) this.renderer.insertBefore( markerIcon?.parentNode, dropdownElement, markerIcon ); if ( markerIcon && !markerIcon.classList?.contains( eMapMarkerString.SELECTED ) ) this.renderer.addClass( markerIcon, eMapMarkerString.SELECTED ); this.isMarkerDropdownOpen = !isClusterMarker; this.isClusterDropdownOpen = isClusterMarker; this.cd.detectChanges(); }, 50); } else { const markerIcon = this.el.nativeElement.querySelector(markerIconId); if (markerIcon?.classList?.contains(eMapMarkerString.SELECTED)) this.renderer.removeClass( markerIcon, eMapMarkerString.SELECTED ); setTimeout(() => { item.options = { zIndex: index + 1 }; }, 50); } }); this.openedInfoWindowData = markerItem; if (!isInfoWindowOpened) this.map!.panTo(markerItem.position); } private closeInfoWindow(): void { this.resetMarkersIcons(); setTimeout(() => (this.openedInfoWindowData = null), 250); clearTimeout(this.openDropdownTimeout); this.isMarkerDropdownOpen = false; this.isClusterDropdownOpen = false; this.selectedRoutingMarker = null; this.resetSelectedMarkerItem.emit(); } private handleRoutingMarkerSelect(markerItem: IMapMarkers): void { let selectedPathIndex: number = -1; this.mapData?.routingMarkers?.map( (item: IMapMarkers, index: number) => { const isSelectedMarker = this.checkInfoWindowPosition( markerItem, item ); const markerIconId = eStringPlaceholder.HASH_SIGN + item.content.id; if (isSelectedMarker) { selectedPathIndex = index - 1; const markerIcon = this.el.nativeElement.querySelector(markerIconId); if ( markerIcon && !markerIcon.classList?.contains( eMapMarkerString.SELECTED ) ) this.renderer.addClass( markerIcon, eMapMarkerString.SELECTED ); if (markerItem.infoWindowContent) { if ( !( markerIcon?.previousSibling === this.clusterDropdown.nativeElement ) ) this.renderer.insertBefore( markerIcon?.parentNode, this.clusterDropdown.nativeElement, markerIcon ); this.isClusterDropdownOpen = true; this.openedInfoWindowData = { ...markerItem }; } this.cd.detectChanges(); return { ...item, options: { zIndex: 999 }, }; } else { const markerIcon = this.el.nativeElement.querySelector(markerIconId); if ( markerIcon?.classList?.contains( eMapMarkerString.SELECTED ) ) this.renderer.removeClass( markerIcon, eMapMarkerString.SELECTED ); return { ...item, options: { zIndex: index + 1 }, }; } } ); this.routePolylines = this.routePolylines?.map((polyline, index) => { let pathStrokeColor = this.routePathColors[index]; const isSelectedPath = selectedPathIndex === index; const isTowingPath = this.routePathColors[index] === MapOptionsConstants.ROUTING_PATH_COLORS['purple']; if (selectedPathIndex > -1) { if (isSelectedPath) pathStrokeColor = isTowingPath ? MapOptionsConstants.ROUTING_PATH_COLORS['darkpurple'] : MapOptionsConstants.ROUTING_PATH_COLORS['darkgray']; else pathStrokeColor = isTowingPath ? MapOptionsConstants.ROUTING_PATH_COLORS['lightpurple'] : MapOptionsConstants.ROUTING_PATH_COLORS['lightgray']; } if (polyline?.icons?.[0]?.icon) { const routePathDashedLine = [ { ...polyline.icons[0], icon: { ...polyline.icons[0].icon, strokeColor: pathStrokeColor, }, }, ]; return { ...polyline, icons: routePathDashedLine, zIndex: isSelectedPath ? 999 : index, }; } return { ...polyline, strokeColor: pathStrokeColor, zIndex: isSelectedPath ? 999 : index, }; }); this.selectedRoutingMarker = markerItem; this.map!.panTo(markerItem.position); } private handleMapData(newData: ICaMapProps): void { this.mapData = { ...newData }; if (this.mapData.selectedMarkerData) this.openMarkerInfoWindow(this.mapData.selectedMarkerData); if (this.mapData.selectedRoutingMarkerData) this.handleRoutingMarkerSelect( this.mapData.selectedRoutingMarkerData ); else if (this.selectedRoutingMarker) this.closeInfoWindow(); if (this.mapData.routePaths?.length !== this.routePathColors.length) this.storeRoutePathColors(); this.areaFilterData = this.mapData.areaFilterData || null; this.setMapStyles(); // Display routes for routingMarkers this.calculateAndDisplayRoute(); setTimeout(() => this.addHoverListeners(), 0); this.cd.detectChanges(); } private addHoverListeners(): void { if (!this.mapMarkers) return; this.mapMarkers.forEach((marker, index) => { const contentEl = (marker as any)._content as HTMLElement; const markerData = this.mapData.routingMarkers?.[index]; if (!contentEl || !markerData?.hasHoverEvent) return; contentEl.onmouseenter = null; contentEl.onmouseleave = null; contentEl.addEventListener('mouseenter', () => { this.handleHover(markerData); }); contentEl.addEventListener('mouseleave', () => { this.resetMarkersIcons(); }); }); } private handleHover(markerData: IMapMarkers): void { if (markerData.isRouteHover && markerData.routeId) { this.highlightMarkersByRoute(markerData.routeId); this.updatePolylineColorsByRoute(markerData.routeId); } else { const selectedMarkerIndex = this.highlightSingleMarker(markerData); this.updatePolylineColorsByMarker(selectedMarkerIndex); } } private highlightMarkersByRoute(routeId: number): void { this.mapData?.routingMarkers?.forEach((item: IMapMarkers) => { if (item.routeId === routeId) { const markerIcon = item.content as HTMLElement; if ( markerIcon && !markerIcon.classList?.contains(eMapMarkerString.SELECTED) ) { this.renderer.addClass( markerIcon, eMapMarkerString.SELECTED ); } } }); } private highlightSingleMarker(markerData: IMapMarkers): number { let selectedMarkerIndex = -1; this.mapData?.routingMarkers?.forEach((item, index) => { const isSelectedMarker = this.checkInfoWindowPosition( markerData, item ); if (isSelectedMarker) { const markerIcon = item.content as HTMLElement; if ( markerIcon && !markerIcon.classList?.contains(eMapMarkerString.SELECTED) ) { this.renderer.addClass( markerIcon, eMapMarkerString.SELECTED ); } selectedMarkerIndex = index; } }); return selectedMarkerIndex; } private updatePolylineColorsByRoute(routeId: number): void { this.routePolylines = this.routePolylines?.map((polyline, index) => { const routeData = this.mapData.routePaths?.[index]; const isSelectedPath = routeData?.id === routeId; return { ...polyline, strokeColor: isSelectedPath ? MapOptionsConstants.ROUTING_PATH_COLORS['darkgray'] : MapOptionsConstants.ROUTING_PATH_COLORS['lightgray'], zIndex: isSelectedPath ? 999 : index, }; }); } private updatePolylineColorsByMarker(selectedMarkerIndex: number): void { this.routePolylines = this.routePolylines?.map((polyline, index) => { const isSelectedPath = selectedMarkerIndex >= 0 && selectedMarkerIndex < this.routePolylines.length && index === selectedMarkerIndex; const isPreviousPath = selectedMarkerIndex - 1 >= 0 && selectedMarkerIndex - 1 < this.routePolylines.length && index === selectedMarkerIndex - 1; let strokeColor = MapOptionsConstants.ROUTING_PATH_COLORS['lightgray']; if (isSelectedPath) { strokeColor = MapOptionsConstants.ROUTING_PATH_COLORS['darkgray']; } else if (isPreviousPath) { strokeColor = MapOptionsConstants.ROUTING_PATH_COLORS['gray']; } return { ...polyline, strokeColor, zIndex: isSelectedPath ? 999 : index, }; }); } private storeRoutePathColors(): void { let pathColors: string[] = []; this.mapData.routePaths?.forEach((path) => { pathColors = [...pathColors, path.strokeColor]; }); this.routePathColors = [...pathColors]; } private getScaleDistance(): void { if (!this.map) return; const { roundedMiles, pixelLength } = MapHelper.getMapScaleDistance( this.map ); if (!roundedMiles || !pixelLength) return; this.mapScaleMilesLabel = roundedMiles; this.mapScalePixelWidth = pixelLength; this.cd.detectChanges(); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } }