feat(location-map): implement reusable location map component with dynamic markers and directions
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<div #mapContainer class="location-map" role="region" [attr.aria-label]="ariaLabel"></div>
|
||||
|
||||
<ul class="visually-hidden">
|
||||
@for (location of locations; track location.id) {
|
||||
<li>
|
||||
{{ location.label }}
|
||||
@if (location.address) {
|
||||
— {{ location.address }}
|
||||
}
|
||||
<a [href]="directionsUrl(location)" target="_blank" rel="noopener noreferrer">
|
||||
Cómo llegar
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.location-map {
|
||||
width: 100%;
|
||||
min-height: 22rem;
|
||||
overflow: hidden;
|
||||
background: #e9ecef;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .leaflet-popup-content {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin: 0.85rem 1rem;
|
||||
color: #343a40;
|
||||
line-height: 1.4;
|
||||
|
||||
a {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--color-primary, #0d6efd);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.location-map {
|
||||
min-height: 18rem;
|
||||
}
|
||||
}
|
||||
172
src/app/shared/components/location-map/location-map.component.ts
Normal file
172
src/app/shared/components/location-map/location-map.component.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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 } from 'leaflet';
|
||||
|
||||
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;
|
||||
|
||||
@ViewChild('mapContainer', { static: true })
|
||||
private readonly mapContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
private leaflet: typeof import('leaflet') | null = null;
|
||||
private map: LeafletMap | 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['locations'] && this.map) {
|
||||
this.renderLocations();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.map?.remove();
|
||||
this.map = 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,
|
||||
});
|
||||
|
||||
leaflet
|
||||
.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
})
|
||||
.addTo(this.map);
|
||||
|
||||
this.markerLayer = leaflet.layerGroup().addTo(this.map);
|
||||
this.renderLocations();
|
||||
}
|
||||
|
||||
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: '#24a9e0',
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user