import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, Component, ElementRef, Inject, Input, OnChanges, OnDestroy, PLATFORM_ID, SimpleChanges, ViewChild, } from '@angular/core'; import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet'; export type MapStyle = 'standard'; const MAP_LAYERS: Record< MapStyle, { url: string; attribution: string; maxZoom: number; } > = { standard: { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19, }, }; export interface MapLocation { id: string | number; label: string; latitude: number; longitude: number; address?: string; } @Component({ selector: 'app-location-map', templateUrl: './location-map.component.html', styleUrl: './location-map.component.scss', }) export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy { @Input({ required: true }) locations: readonly MapLocation[] = []; @Input() ariaLabel = 'Mapa de ubicaciones'; @Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932]; @Input() fallbackZoom = 13; @Input() maxFitZoom = 15; @Input() mapStyle: MapStyle = 'standard'; @Input() markerColor = '#24a9e0'; @ViewChild('mapContainer', { static: true }) private readonly mapContainer!: ElementRef; private leaflet: typeof import('leaflet') | null = null; private map: LeafletMap | null = null; private baseLayer: TileLayer | null = null; private markerLayer: LayerGroup | null = null; constructor( @Inject(PLATFORM_ID) private readonly platformId: object, @Inject(DOCUMENT) private readonly document: Document, ) {} ngAfterViewInit(): void { if (isPlatformBrowser(this.platformId)) { void this.initializeMap(); } } ngOnChanges(changes: SimpleChanges): void { if (changes['mapStyle'] && this.map) { this.renderBaseLayer(); } if ((changes['locations'] || changes['markerColor']) && this.map) { this.renderLocations(); } } ngOnDestroy(): void { this.map?.remove(); this.map = null; this.baseLayer = null; this.markerLayer = null; } protected directionsUrl(location: MapLocation): string { const destination = `${location.latitude},${location.longitude}`; return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(destination)}`; } private async initializeMap(): Promise { const leafletModule = await import('leaflet'); const leaflet = ( leafletModule as unknown as { default?: typeof import('leaflet'); } ).default ?? leafletModule; if (!this.mapContainer.nativeElement.isConnected || this.map) { return; } this.leaflet = leaflet; this.map = leaflet.map(this.mapContainer.nativeElement, { center: this.fallbackCenter, zoom: this.fallbackZoom, scrollWheelZoom: false, }); this.renderBaseLayer(); this.markerLayer = leaflet.layerGroup().addTo(this.map); this.renderLocations(); } private renderBaseLayer(): void { if (!this.leaflet || !this.map) { return; } if (this.baseLayer) { this.map.removeLayer(this.baseLayer); } const layer = MAP_LAYERS[this.mapStyle]; this.baseLayer = this.leaflet .tileLayer(layer.url, { maxZoom: layer.maxZoom, attribution: layer.attribution, crossOrigin: true, }) .addTo(this.map); } private renderLocations(): void { if (!this.leaflet || !this.map || !this.markerLayer) { return; } this.markerLayer.clearLayers(); const validLocations = this.locations.filter( ({ latitude, longitude }) => Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180, ); if (validLocations.length === 0) { this.map.setView(this.fallbackCenter, this.fallbackZoom); return; } const bounds: LatLngBounds = this.leaflet.latLngBounds([]); for (const location of validLocations) { const coordinates: LatLngTuple = [location.latitude, location.longitude]; bounds.extend(coordinates); const popup = this.document.createElement('div'); const title = this.document.createElement('strong'); title.textContent = location.label; popup.append(title); if (location.address) { const address = this.document.createElement('div'); address.textContent = location.address; popup.append(address); } const directions = this.document.createElement('a'); directions.href = this.directionsUrl(location); directions.target = '_blank'; directions.rel = 'noopener noreferrer'; directions.textContent = 'Cómo llegar'; popup.append(directions); const tooltip = this.document.createElement('span'); tooltip.textContent = location.label; this.leaflet .circleMarker(coordinates, { radius: 8, color: '#ffffff', weight: 3, fillColor: this.markerColor, fillOpacity: 1, }) .bindTooltip(tooltip) .bindPopup(popup) .addTo(this.markerLayer); } if (validLocations.length === 1) { this.map.setView(bounds.getCenter(), this.maxFitZoom); } else { this.map.fitBounds(bounds, { padding: [32, 32], maxZoom: this.maxFitZoom, }); } } }