feat(location-map): implement reusable location map component with dynamic markers and directions

This commit is contained in:
2026-07-24 09:05:16 -03:00
parent 1d5c94b9bc
commit 63ae02df22
9 changed files with 330 additions and 12 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View 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: '&copy; 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,
});
}
}
}