From aec2290b69daa48c8be5768d99eafbcbb124acae Mon Sep 17 00:00:00 2001 From: xavs Date: Mon, 18 May 2026 18:12:05 +0200 Subject: [PATCH] Add map stations feature with Leaflet integration; update EstacioXema model and enhance routing --- angular.json | 3 +- package-lock.json | 23 ++ package.json | 2 + src/app/app.routes.ts | 2 + src/app/components/home/home.html | 5 + src/app/components/home/home.ts | 21 +- .../mapa-estacions/mapa-estacions.html | 71 ++++ .../mapa-estacions/mapa-estacions.scss | 305 ++++++++++++++++++ .../mapa-estacions/mapa-estacions.ts | 171 ++++++++++ src/app/models/estacio-xema.model.ts | 20 +- 10 files changed, 612 insertions(+), 11 deletions(-) create mode 100644 src/app/components/mapa-estacions/mapa-estacions.html create mode 100644 src/app/components/mapa-estacions/mapa-estacions.scss create mode 100644 src/app/components/mapa-estacions/mapa-estacions.ts diff --git a/angular.json b/angular.json index b54541e..abb904c 100644 --- a/angular.json +++ b/angular.json @@ -35,7 +35,8 @@ } ], "styles": [ - "src/styles.scss" + "src/styles.scss", + "node_modules/leaflet/dist/leaflet.css" ] }, "configurations": { diff --git a/package-lock.json b/package-lock.json index 397c971..1cdee23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@angular/material": "^21.2.11", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@types/leaflet": "^1.9.21", "chart.js": "^4.5.1", + "leaflet": "^1.9.4", "moment": "^2.30.1", "ng2-charts": "^10.0.0", "rxjs": "~7.8.0", @@ -4051,6 +4053,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -6089,6 +6106,12 @@ ], "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", diff --git a/package.json b/package.json index d1a4a75..84161d5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "@angular/material": "^21.2.11", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@types/leaflet": "^1.9.21", "chart.js": "^4.5.1", + "leaflet": "^1.9.4", "moment": "^2.30.1", "ng2-charts": "^10.0.0", "rxjs": "~7.8.0", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8248bff..252cff2 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,7 +1,9 @@ import { Routes } from '@angular/router'; import { Home } from './components/home/home'; +import { MapaEstacions } from './components/mapa-estacions/mapa-estacions'; export const routes: Routes = [ { path: '', component: Home }, + { path: 'mapa', component: MapaEstacions }, { path: '**', redirectTo: '' } ]; diff --git a/src/app/components/home/home.html b/src/app/components/home/home.html index 43fe116..1a9f3dc 100644 --- a/src/app/components/home/home.html +++ b/src/app/components/home/home.html @@ -67,6 +67,11 @@ + + + diff --git a/src/app/components/home/home.ts b/src/app/components/home/home.ts index d047165..1f17406 100644 --- a/src/app/components/home/home.ts +++ b/src/app/components/home/home.ts @@ -1,5 +1,6 @@ import { Component, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Router, ActivatedRoute } from '@angular/router'; import { MeteocatService } from '../../services/meteocat.service'; import { Municipi } from '../../models/municipi.model'; import { PrediccioDiaria } from '../../models/prediccio-diaria.model'; @@ -38,9 +39,23 @@ export class Home implements OnInit { showGpsModal = signal(false); gpsPermissionAsked = signal(false); - constructor(private meteocatService: MeteocatService) {} + constructor( + private meteocatService: MeteocatService, + private router: Router, + private route: ActivatedRoute + ) {} ngOnInit(): void { + // Verificar si viene un municipio desde el mapa + this.route.queryParams.subscribe(params => { + if (params['municipi']) { + this.municipiCodi.set(params['municipi']); + this.gpsPermissionAsked.set(true); + this.loadWeatherData(params['municipi']); + return; + } + }); + // Verificar si ya se pidió permiso GPS antes const gpsRejected = localStorage.getItem('gpsRejected'); const gpsAsked = localStorage.getItem('gpsAsked'); @@ -292,4 +307,8 @@ export class Home implements OnInit { return null; } } + + openMap(): void { + this.router.navigate(['/mapa']); + } } diff --git a/src/app/components/mapa-estacions/mapa-estacions.html b/src/app/components/mapa-estacions/mapa-estacions.html new file mode 100644 index 0000000..0425ce3 --- /dev/null +++ b/src/app/components/mapa-estacions/mapa-estacions.html @@ -0,0 +1,71 @@ +
+ +
+ +

Mapa d'Estacions XEMA

+
+
+ + +
+ + + + + + Mostrant: {{ getFilteredCount() }} / {{ estacions().length }} + +
+ + + @if (loading()) { +
+
+

Carregant estacions...

+
+ } + + + @if (error()) { +
+

{{ error() }}

+ +
+ } + + +
+ + +
+
+ 🌡️ + Estació meteorològica +
+
+ Fes clic en un marcador per veure el temps d'aquell municipi +
+
+
diff --git a/src/app/components/mapa-estacions/mapa-estacions.scss b/src/app/components/mapa-estacions/mapa-estacions.scss new file mode 100644 index 0000000..873c6d5 --- /dev/null +++ b/src/app/components/mapa-estacions/mapa-estacions.scss @@ -0,0 +1,305 @@ +.mapa-container { + height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #072d59 0%, #56556a 100%); + color: white; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 15px; + background: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + @media (min-width: 768px) { + padding: 16px 20px; + } + + h1 { + margin: 0; + font-size: 18px; + font-weight: 600; + + @media (min-width: 768px) { + font-size: 24px; + } + } + + .spacer { + width: 80px; // Para balancear el botón + + @media (min-width: 768px) { + width: 100px; + } + } +} + +.back-button { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + + @media (min-width: 768px) { + padding: 10px 20px; + font-size: 16px; + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &:active { + transform: scale(0.98); + } +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px 15px; + background: rgba(0, 0, 0, 0.15); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + align-items: center; + + @media (min-width: 768px) { + padding: 15px 20px; + gap: 20px; + } +} + +.filter-checkbox { + display: flex; + align-items: center; + cursor: pointer; + font-size: 13px; + + @media (min-width: 768px) { + font-size: 15px; + } + + input[type="checkbox"] { + margin-right: 6px; + cursor: pointer; + width: 16px; + height: 16px; + + @media (min-width: 768px) { + width: 18px; + height: 18px; + } + } + + .checkbox-label { + display: flex; + align-items: center; + gap: 6px; + } +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + + &.green { + background: #4caf50; + box-shadow: 0 0 6px rgba(76, 175, 80, 0.6); + } + + &.orange { + background: #ff9800; + box-shadow: 0 0 6px rgba(255, 152, 0, 0.6); + } +} + +.total-count { + margin-left: auto; + font-size: 12px; + opacity: 0.8; + + @media (min-width: 768px) { + font-size: 14px; + } +} + +.map { + flex: 1; + width: 100%; + position: relative; +} + +.loading, .error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + padding: 20px; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error button { + margin-top: 20px; + padding: 12px 24px; + background: white; + color: #4a90e2; + border: none; + border-radius: 20px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + + &:hover { + opacity: 0.9; + } +} + +.legend { + padding: 10px 15px; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid rgba(255, 255, 255, 0.1); + + @media (min-width: 768px) { + padding: 12px 20px; + } +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + margin-bottom: 6px; + + @media (min-width: 768px) { + font-size: 14px; + } + + .legend-icon { + font-size: 18px; + + @media (min-width: 768px) { + font-size: 20px; + } + } +} + +.legend-note { + font-size: 11px; + opacity: 0.7; + margin-top: 4px; + + @media (min-width: 768px) { + font-size: 13px; + } +} + +// Estilos para los marcadores personalizados +:host ::ng-deep { + .custom-marker { + background: transparent; + border: none; + } + + .marker-pin { + width: 30px; + height: 42px; + position: relative; + + &::before { + content: ''; + position: absolute; + width: 24px; + height: 24px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + left: 3px; + top: 0; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + + &.green::before { + background: #4caf50; + } + + &.orange::before { + background: #ff9800; + } + + .marker-icon { + position: absolute; + top: 2px; + left: 8px; + font-size: 16px; + z-index: 1; + } + } + + // Tooltips + .leaflet-tooltip { + background: rgba(0, 0, 0, 0.85); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: white; + font-size: 12px; + padding: 8px 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + + &::before { + border-top-color: rgba(0, 0, 0, 0.85); + } + + .station-tooltip { + strong { + display: block; + margin-bottom: 4px; + font-size: 13px; + } + + .status { + display: inline-block; + margin-top: 4px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + + &.operativa { + background: rgba(76, 175, 80, 0.3); + color: #4caf50; + } + + &.baixa-temporal { + background: rgba(255, 152, 0, 0.3); + color: #ff9800; + } + } + } + } +} diff --git a/src/app/components/mapa-estacions/mapa-estacions.ts b/src/app/components/mapa-estacions/mapa-estacions.ts new file mode 100644 index 0000000..4e06dc2 --- /dev/null +++ b/src/app/components/mapa-estacions/mapa-estacions.ts @@ -0,0 +1,171 @@ +import { Component, OnInit, AfterViewInit, OnDestroy, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import * as L from 'leaflet'; +import { MeteocatService } from '../../services/meteocat.service'; +import { EstacioXema } from '../../models/estacio-xema.model'; + +@Component({ + selector: 'app-mapa-estacions', + imports: [CommonModule], + templateUrl: './mapa-estacions.html', + styleUrl: './mapa-estacions.scss', +}) +export class MapaEstacions implements OnInit, AfterViewInit, OnDestroy { + private map: L.Map | null = null; + private markers: L.Marker[] = []; + + estacions = signal([]); + loading = signal(true); + error = signal(null); + + // Filtros + showOperatives = signal(true); + showBaixaTemporal = signal(true); + + constructor( + private meteocatService: MeteocatService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadEstacions(); + } + + ngAfterViewInit(): void { + this.initMap(); + } + + ngOnDestroy(): void { + if (this.map) { + this.map.remove(); + } + } + + loadEstacions(): void { + this.loading.set(true); + this.meteocatService.getAllEstacions().subscribe({ + next: (estacions) => { + this.estacions.set(estacions); + this.loading.set(false); + if (this.map) { + this.addMarkers(); + } + }, + error: (err) => { + console.error('Error loading estacions:', err); + this.error.set('Error al carregar les estacions meteorològiques'); + this.loading.set(false); + } + }); + } + + initMap(): void { + // Crear mapa centrado en Cataluña + this.map = L.map('map', { + center: [41.8, 1.5], + zoom: 8, + minZoom: 7, + maxZoom: 18 + }); + + // Añadir capa de OpenStreetMap + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + + // Añadir marcadores si ya se cargaron las estaciones + if (this.estacions().length > 0) { + this.addMarkers(); + } + } + + addMarkers(): void { + if (!this.map) return; + + // Limpiar marcadores anteriores + this.markers.forEach(marker => marker.remove()); + this.markers = []; + + const filteredEstacions = this.estacions().filter(estacio => { + if (estacio.estat === 'Operativa' && this.showOperatives()) return true; + if (estacio.estat === 'Baixa temporal' && this.showBaixaTemporal()) return true; + return false; + }); + + filteredEstacions.forEach(estacio => { + if (!estacio.coordenadesLatitud || !estacio.coordenadesLongitud) { + return; + } + + // Icono personalizado según estado + const iconColor = estacio.estat === 'Operativa' ? 'green' : 'orange'; + const icon = L.divIcon({ + className: 'custom-marker', + html: ` +
+
🌡️
+
+ `, + iconSize: [30, 42], + iconAnchor: [15, 42], + popupAnchor: [0, -42] + }); + + const marker = L.marker( + [estacio.coordenadesLatitud, estacio.coordenadesLongitud], + { icon } + ).addTo(this.map!); + + // Tooltip (hover) + marker.bindTooltip(` +
+ ${estacio.nom}
+ ${estacio.emplacament || ''}
+ ${estacio.estat} +
+ `, { + direction: 'top', + offset: [0, -42] + }); + + // Popup (click) - navegar al municipio + marker.on('click', () => { + if (estacio.municipiCodi) { + this.navigateToMunicipi(estacio.municipiCodi); + } + }); + + this.markers.push(marker); + }); + } + + navigateToMunicipi(municipiCodi: string): void { + // Cerrar el mapa y volver a Home con el municipio seleccionado + this.router.navigate(['/'], { + queryParams: { municipi: municipiCodi } + }); + } + + toggleOperatives(): void { + this.showOperatives.set(!this.showOperatives()); + this.addMarkers(); + } + + toggleBaixaTemporal(): void { + this.showBaixaTemporal.set(!this.showBaixaTemporal()); + this.addMarkers(); + } + + getFilteredCount(): number { + return this.estacions().filter(estacio => { + if (estacio.estat === 'Operativa' && this.showOperatives()) return true; + if (estacio.estat === 'Baixa temporal' && this.showBaixaTemporal()) return true; + return false; + }).length; + } + + goBack(): void { + this.router.navigate(['/']); + } +} diff --git a/src/app/models/estacio-xema.model.ts b/src/app/models/estacio-xema.model.ts index 8d3a1a0..6bbbc79 100644 --- a/src/app/models/estacio-xema.model.ts +++ b/src/app/models/estacio-xema.model.ts @@ -4,13 +4,15 @@ export interface EstacioXema { tipus: string; coordenadesLatitud: number; coordenadesLongitud: number; - altitud?: number; - municipi?: string; - comarca?: string; - provincia?: string; - xarxa?: string; - estat?: string; - dataInici?: string; - dataFi?: string; - distancia?: number; // Calculado dinámicamente para estación más cercana + altitud: number; + emplacament: string; + municipiCodi: string; + comarcaId: number; + provinciaCodi: string; + xarxaCodi: string; + estat: string; + dataInici: string | null; + dataFi: string | null; + createdAt: string; + updatedAt: string; }