212 lines
5.5 KiB
TypeScript
212 lines
5.5 KiB
TypeScript
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:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<HTMLDivElement>;
|
|
|
|
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<void> {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|