Compare commits

...
2 Commits
10 changed files with 612 additions and 11 deletions
+2 -1
View File
@@ -35,7 +35,8 @@
} }
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss",
"node_modules/leaflet/dist/leaflet.css"
] ]
}, },
"configurations": { "configurations": {
+23
View File
@@ -16,7 +16,9 @@
"@angular/material": "^21.2.11", "@angular/material": "^21.2.11",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@types/leaflet": "^1.9.21",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"leaflet": "^1.9.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"ng2-charts": "^10.0.0", "ng2-charts": "^10.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -4051,6 +4053,21 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz",
@@ -6089,6 +6106,12 @@
], ],
"license": "MIT" "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": { "node_modules/listr2": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+2
View File
@@ -19,7 +19,9 @@
"@angular/material": "^21.2.11", "@angular/material": "^21.2.11",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@types/leaflet": "^1.9.21",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"leaflet": "^1.9.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"ng2-charts": "^10.0.0", "ng2-charts": "^10.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
+2
View File
@@ -1,7 +1,9 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Home } from './components/home/home'; import { Home } from './components/home/home';
import { MapaEstacions } from './components/mapa-estacions/mapa-estacions';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
{ path: 'mapa', component: MapaEstacions },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
]; ];
+5
View File
@@ -67,6 +67,11 @@
<button class="menu-action secondary" (click)="requestLocation(); closeMenu()"> <button class="menu-action secondary" (click)="requestLocation(); closeMenu()">
📍 Usar la meva ubicació 📍 Usar la meva ubicació
</button> </button>
<!-- Botón de mapa de estaciones -->
<button class="menu-action secondary" (click)="openMap(); closeMenu()">
🗺️ Mapa d'estacions
</button>
</div> </div>
</div> </div>
+20 -1
View File
@@ -1,5 +1,6 @@
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { MeteocatService } from '../../services/meteocat.service'; import { MeteocatService } from '../../services/meteocat.service';
import { Municipi } from '../../models/municipi.model'; import { Municipi } from '../../models/municipi.model';
import { PrediccioDiaria } from '../../models/prediccio-diaria.model'; import { PrediccioDiaria } from '../../models/prediccio-diaria.model';
@@ -38,9 +39,23 @@ export class Home implements OnInit {
showGpsModal = signal(false); showGpsModal = signal(false);
gpsPermissionAsked = signal(false); gpsPermissionAsked = signal(false);
constructor(private meteocatService: MeteocatService) {} constructor(
private meteocatService: MeteocatService,
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit(): void { 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 // Verificar si ya se pidió permiso GPS antes
const gpsRejected = localStorage.getItem('gpsRejected'); const gpsRejected = localStorage.getItem('gpsRejected');
const gpsAsked = localStorage.getItem('gpsAsked'); const gpsAsked = localStorage.getItem('gpsAsked');
@@ -292,4 +307,8 @@ export class Home implements OnInit {
return null; return null;
} }
} }
openMap(): void {
this.router.navigate(['/mapa']);
}
} }
@@ -0,0 +1,71 @@
<div class="mapa-container">
<!-- Header con título y botón volver -->
<div class="header">
<button class="back-button" (click)="goBack()">
← Tornar
</button>
<h1>Mapa d'Estacions XEMA</h1>
<div class="spacer"></div>
</div>
<!-- Filtros -->
<div class="filters">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showOperatives()"
(change)="toggleOperatives()"
/>
<span class="checkbox-label">
<span class="status-dot green"></span>
Operatives ({{ estacions().filter(e => e.estat === 'Operativa').length }})
</span>
</label>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showBaixaTemporal()"
(change)="toggleBaixaTemporal()"
/>
<span class="checkbox-label">
<span class="status-dot orange"></span>
Baixa temporal ({{ estacions().filter(e => e.estat === 'Baixa temporal').length }})
</span>
</label>
<span class="total-count">
Mostrant: {{ getFilteredCount() }} / {{ estacions().length }}
</span>
</div>
<!-- Loading -->
@if (loading()) {
<div class="loading">
<div class="spinner"></div>
<p>Carregant estacions...</p>
</div>
}
<!-- Error -->
@if (error()) {
<div class="error">
<p>{{ error() }}</p>
<button (click)="loadEstacions()">Tornar a intentar</button>
</div>
}
<!-- Mapa -->
<div id="map" class="map"></div>
<!-- Leyenda -->
<div class="legend">
<div class="legend-item">
<span class="legend-icon">🌡️</span>
<span>Estació meteorològica</span>
</div>
<div class="legend-note">
Fes clic en un marcador per veure el temps d'aquell municipi
</div>
</div>
</div>
@@ -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;
}
}
}
}
}
@@ -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<EstacioXema[]>([]);
loading = signal(true);
error = signal<string | null>(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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `
<div class="marker-pin ${iconColor}">
<div class="marker-icon">🌡️</div>
</div>
`,
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(`
<div class="station-tooltip">
<strong>${estacio.nom}</strong><br/>
${estacio.emplacament || ''}<br/>
<span class="status ${estacio.estat.toLowerCase().replace(' ', '-')}">${estacio.estat}</span>
</div>
`, {
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(['/']);
}
}
+11 -9
View File
@@ -4,13 +4,15 @@ export interface EstacioXema {
tipus: string; tipus: string;
coordenadesLatitud: number; coordenadesLatitud: number;
coordenadesLongitud: number; coordenadesLongitud: number;
altitud?: number; altitud: number;
municipi?: string; emplacament: string;
comarca?: string; municipiCodi: string;
provincia?: string; comarcaId: number;
xarxa?: string; provinciaCodi: string;
estat?: string; xarxaCodi: string;
dataInici?: string; estat: string;
dataFi?: string; dataInici: string | null;
distancia?: number; // Calculado dinámicamente para estación más cercana dataFi: string | null;
createdAt: string;
updatedAt: string;
} }