1/** 2 * MeteoPomarico - Frontend Application 3 */ 4 5(function () { 6 'use strict'; 7 8 const API_URL = 'api/weather.php'; 9 const FORECAST_URL = 'api/forecast.php'; 10 const ALERTS_URL = 'api/alerts.php'; 11 const RECORDS_URL = 'api/records.php'; 12 const STATS_URL = 'api/stats.php'; 13 const MARINE_URL = 'api/marine.php'; 14 const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minuti 15 let chart = null; 16 let currentChartType = 'temperature'; 17 let currentChartRange = '24h'; 18 let useFahrenheit = false; 19 20 // === INIZIALIZZAZIONE === 21 document.addEventListener('DOMContentLoaded', function () { 22 I18N.init(); 23 initLangToggle(); 24 initThemeToggle(); 25 initUnitToggle(); 26 loadWeatherData(); 27 loadForecastData(); 28 loadAlerts(); 29 loadRecords(); 30 loadStats(); 31 updateMoonPhase(); 32 initShareBtn(); 33 initMobileMapGestures(); 34 loadMarineData(); 35 initTempestWebSocket(); // Vento real-time ogni 3 secondi 36 setInterval(loadWeatherData, REFRESH_INTERVAL); 37 setInterval(loadForecastData, 30 * 60 * 1000); // 30 min 38 setInterval(loadMarineData, 30 * 60 * 1000); // 30 min 39 40 // Chart type tabs (temperatura / umidita / pressione) 41 document.querySelectorAll('.chart-tab').forEach(function (tab) { 42 tab.addEventListener('click', function () { 43 document.querySelectorAll('.chart-tab').forEach(function (t) { t.classList.remove('active'); }); 44 tab.classList.add('active'); 45 currentChartType = tab.dataset.type; 46 if (currentChartRange === '24h') { 47 loadWeatherData(); 48 } else { 49 loadChartHistory(currentChartRange); 50 } 51 }); 52 }); 53 54 // Chart range tabs (24h / 7d / 30d) 55 document.querySelectorAll('.chart-range-tab').forEach(function (btn) { 56 btn.addEventListener('click', function () { 57 document.querySelectorAll('.chart-range-tab').forEach(function (b) { b.classList.remove('active'); }); 58 btn.classList.add('active'); 59 currentChartRange = btn.dataset.range; 60 updateChartTitle(currentChartRange); 61 if (currentChartRange === '24h') { 62 loadWeatherData(); 63 } else { 64 loadChartHistory(currentChartRange); 65 } 66 }); 67 }); 68 }); 69 70 // === FETCH DATI STAZIONE === 71 function loadWeatherData() { 72 fetch(API_URL) 73 .then(function (response) { 74 if (!response.ok) throw new Error('HTTP ' + response.status); 75 return response.json(); 76 }) 77 .then(function (data) { 78 updateUI(data); 79 updateChart(data.history, '24h'); 80 renderWindRose(data.history); 81 renderRainChart(data.history); 82 updateLastRefresh(); 83 // Aggiorna SOLO la bussola della stazione meteo Pomarico. 84 // (Non tocca la bussola/advice marine: quelli prendono il 85 // vento dall'aggregato delle spiagge in api/marine.php.) 86 if (data.current && data.current.wind) { 87 updateMarineWindFromStation(data.current.wind); 88 } 89 }) 90 .catch(function (err) { 91 console.error('Errore caricamento dati stazione:', err); 92 }); 93 } 94 95 // === FETCH PREVISIONI + AQ + UV === 96 function loadForecastData() { 97 fetch(FORECAST_URL) 98 .then(function (response) { 99 if (!response.ok) throw new Error('HTTP ' + response.status); 100 return response.json(); 101 }) 102 .then(function (data) { 103 if (data.forecast) { 104 renderForecast(data.forecast.daily); 105 renderHourly(data.forecast.hourly, data.forecast.daily); 106 } 107 if (data.air_quality) { 108 updateAirQuality(data.air_quality); 109 } 110 if (data.pollen) { 111 updatePollen(data.pollen); 112 } 113 // Percepita: usa solo i dati della stazione Tempest (piu' precisi) 114 // Open-Meteo apparent_temperature non serve piu' 115 // Pioggia: la stazione Tempest ha il pluviometro, nessun fallback necessario 116 if (data.sun) { 117 updateSunData(data.sun); 118 } 119 if (data.sea) { 120 updateSeaTemp(data.sea); 121 } 122 }) 123 .catch(function (err) { 124 console.error('Errore caricamento previsioni:', err); 125 }); 126 } 127 128 // === AGGIORNA UI STAZIONE (tutti i dati Tempest) === 129 function updateUI(data) { 130 var current = data.current; 131 if (!current) return; 132 var mm = data.daily_minmax || {}; 133 134 // --- TEMPERATURA --- 135 setTextIfExists('hero-temp-value', formatTemp(current.outdoor?.temperature)); 136 setTextIfExists('hero-temp-min', formatTemp(current.outdoor?.temp_min ?? mm.temp_min)); 137 setTextIfExists('hero-temp-max', formatTemp(current.outdoor?.temp_max ?? mm.temp_max)); 138 139 // Aggiorna titolo tab con temperatura corrente (rispetta unità C/F) 140 if (current.outdoor?.temperature != null) { 141 document.title = 'MeteoPomarico â ' + formatTemp(current.outdoor.temperature); 142 } 143 144 var trendEl = document.getElementById('hero-temp-trend'); 145 if (trendEl && current.outdoor?.temp_trend) { 146 trendEl.className = 'trend-icon ' + getTrendClass(current.outdoor.temp_trend); 147 trendEl.innerHTML = getTrendIcon(current.outdoor.temp_trend); 148 trendEl.title = I18N.t({ up: 'trend_up_short', down: 'trend_down_short', stable: 'trend_stable_short' }[current.outdoor.temp_trend] || 'trend_stable_short'); 149 } 150 151 // --- UMIDITA --- 152 setTextIfExists('humidity-value', current.outdoor?.humidity != null ? current.outdoor.humidity + '%' : '--'); 153 var humMinMax = document.getElementById('humidity-minmax'); 154 if (humMinMax && mm.hum_min != null) { 155 humMinMax.innerHTML = 156 '<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewB
156ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m18 15-6-6-6 6"/></svg> <span style="opacity:.65;font-size:.7rem;">Max</span> ' + (mm.hum_max ?? '--') + '%' + 157 ' ' + 158 '<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg> <span style="opacity:.65;font-size:.7rem;">Min</span> ' + (mm.hum_min ?? '--') + '%'; 159 } 160 161 // --- PRESSIONE --- 162 setTextIfExists('pressure-value', current.pressure != null ? current.pressure.toFixed(1) : '--'); 163 var pressBar = document.getElementById('pressure-bar-fill'); 164 if (pressBar && current.pressure) { 165 animateBar(pressBar, Math.max(0, Math.min(100, ((current.pressure - 980) / 60) * 100))); 166 } 167 if (current.pressure_trend) { 168 var pressTrend = document.getElementById('pressure-trend'); 169 if (pressTrend) { 170 pressTrend.className = 'trend-icon ' + getTrendClass(current.pressure_trend); 171 pressTrend.innerHTML = getTrendIcon(current.pressure_trend); 172 } 173 var pressTrendLabel = document.getElementById('pressure-trend-label'); 174 if (pressTrendLabel) { 175 pressTrendLabel.textContent = I18N.t({ up: 'trend_up', down: 'trend_down', stable: 'trend_stable' }[current.pressure_trend] || 'trend_stable'); 176 } 177 } 178 179 // --- MIN/MAX GIORNALIERO --- 180 setTextIfExists('daily-temp-min', formatTemp(mm.temp_min)); 181 setTextIfExists('daily-temp-max', formatTemp(mm.temp_max)); 182 183 // --- UV (dalla stazione Tempest) --- 184 if (current.uv && current.uv.index != null) { 185 var uvEl = document.getElementById('uv-value'); 186 var uvLabel = document.getElementById('uv-label'); 187 if (uvEl) { 188 uvEl.textContent = current.uv.index.toFixed(1); 189 uvEl._stationUV = true; 190 var uvInfo = getUVInfo(current.uv.index); 191 uvEl.closest('.card-value').className = 'card-value ' + uvInfo.cls; 192 if (uvLabel) uvLabel.textContent = uvInfo.label; 193 } 194 } 195 196 // --- INDICE COMFORT OUTDOOR --- 197 var heroComfortEl = document.getElementById('hero-comfort'); 198 if (heroComfortEl && current.outdoor?.temperature != null) { 199 var cTemp = current.outdoor.temperature; 200 var cHum = current.outdoor?.humidity != null ? current.outdoor.humidity : 50; 201 var cWind = current.wind?.speed != null ? current.wind.speed : 0; 202 var cUV = current.uv?.index != null ? current.uv.index : 0; 203 // Pioggia per comfort: cumulato 60 min (stabile). Fallback al rate istantaneo 204 // (mm/min * 60 = mm/h) se l'orario non è disponibile. Allineato al backend PHP. 205 var cRainRate = current.rain?.hour != null 206 ? current.rain.hour 207 : (current.rain?.current != null ? current.rain.current * 60 : 0); 208 209 // Temperature score: peak at 22, 0-10 210 var tempScore = Math.max(0, 10 - Math.abs(cTemp - 22) * 0.8); 211 // Humidity score: peak at 50, 0-10 212 var humScore = Math.max(0, 10 - Math.abs(cHum - 50) * 0.2);
213 // Wind score: 0=7, 10=10, 30=5, 60=0 214 var windScore; 215 if (cWind <= 10) { windScore = 7 + (cWind / 10) * 3; } 216 else if (cWind <= 30) { windScore = 10 - ((cWind - 10) / 20) * 5; } 217 else { windScore = Math.max(0, 5 - ((cWind - 30) / 30) * 5); } 218 219 // Weighted average 220 var comfortRaw = (tempScore * 0.4) + (humScore * 0.25) + (windScore * 0.35); 221 222 // UV penalty 223 if (cUV > 8) comfortRaw -= 2; 224 // Rain penalty 225 if (cRainRate > 0.05) comfortRaw -= 3; 226 227 // Clamp 1-10 228 var comfortVal = Math.max(1, Math.min(10, comfortRaw)); 229 230 // Label and emoji 231 var comfortLabel, comfortEmoji, comfortColor; 232 if (comfortVal >= 8) { comfortLabel = I18N.t('comfort_ideal'); comfortEmoji = '\u2600\uFE0F'; } 233 else if (comfortVal >= 6) { comfortLabel = I18N.t('comfort_pleasant'); comfortEmoji = '\uD83D\uDE0A'; } 234 else if (comfortVal >= 4) { comfortLabel = I18N.t('comfort_fair'); comfortEmoji = '\uD83C\uDF25\uFE0F'; } 235 else if (comfortVal >= 2) { comfortLabel = I18N.t('comfort_unpleasant'); comfortEmoji = '\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F'; } 236 else { comfortLabel = I18N.t('comfort_extreme'); comfortEmoji = '\uD83E\uDD76'; } 237 238 // Color 239 if (comfortVal >= 7) comfortColor = '#22c55e'; 240 else if (comfortVal >= 5) comfortColor = '#eab308'; 241 else if (comfortVal >= 3) comfortColor = '#f97316'; 242 else comfortColor = '#ef4444'; 243 244 heroComfortEl.style.display = ''; 245 heroComfortEl.innerHTML = '<span>' + comfortEmoji + ' Comfort: <strong style="color:' + comfortColor + ';">' + comfortVal.toFixed(1) + '/10</strong> \u2014 ' + comfortLabel + '</span>'; 246 } 247 248 // --- INDICATORE PIOGGIA NELLA HERO --- 249 var heroRainEl = document.getElementById('hero-rain-indicator'); 250 if (heroRainEl && current.rain) { 251 var heroIntensity = getRainIntensity(current.rain); 252 if (heroIntensity.rate > 0) { 253 heroRainEl.style.display = ''; 254 heroRainEl.innerHTML = 255 '<span class="hero-rain-dot" style="background:' + heroIntensity.color + ';"></span>' + 256 '<span style="color:' + heroIntensity.color + ';font-weight:600;">' + heroIntensity.label + '</span>' + 257 '<span style="opacity:0.7;">(' + heroIntensity.rate.toFixed(1) + ' mm/h)</span>'; 258 } else { 259 heroRainEl.style.display = 'none'; 260 } 261 } 262 263 // --- PIOGGIA --- 264 var rainSection = document.getElementById('rain-section'); 265 if (rainSection && current.rain) { 266 rainSection.innerHTML = buildRainCard(current.rain); 267 rainSection._stationLoaded = true; 268 // Salva dati per il WebSocket (che non ha last_1hr/yesterday) 269 rainSection._lastStationRain = current.rain; 270 } 271 272 // --- PUNTO DI RUGIADA (dalla stazione) --- 273 var temp = current.outdoor?.temperature; 274 var dewPoint = current.outdoor?.dew_point; 275 if (dewPoint != null) { 276 setTextIfExists('dew-point-value', dewPoint.toFixed(1)); 277 var dpLabel = document.getElementById('dew-point-label'); 278 if (dpLabel) dpLabel.textContent = getDewPointLabel(dewPoint); 279 } else if (temp != null && current.outdoor?.humidity != null) { 280 dewPoint = calcDewPoint(temp, current.outdoor.humidity); 281 setTextIfExists('dew-point-value', dewPoint.toFixed(1)); 282 var dpLabel2 = document.getElementById('dew-point-label'); 283 if (dpLabel2) dpLabel2.textContent = getDewPointLabel(dewPoint); 284 } 285 286 // --- TEMPERATURA PERCEPITA (dalla stazione) --- 287 var feelsLike = current.outdoor?.feels_like; 288 if (feelsLike != null) { 289 setTextIfExists('feels-like-value', feelsLike.toFixed(1)); 290 var flLabel = document.getElementById('feels-like-label'); 291 if (flLabel && temp != null) flLabel.textContent = getFeelsLikeLabel(temp, feelsLike); 292 } 293 294 // --- FULMINI (come Tempest: ultimo rilevato, distanza, ultimi 3h) --- 295 if (current.lightning) { 296 var dailyLightning = mm.lightning_total || 0; 297 var lCountEl = document.getElementById('lightning-count'); 298 var lDetailEl = document.getElementById('lightning-distance'); 299 300 if (current.lightning.last_epoch && current.lightning.last_epoch > 0) { 301 // C'e' stato almeno un fulmine: mostra "ultimo X fa" 302 var lastTime = new Date(current.lightning.last_epoch * 1000); 303 var agoMs = Date.now() - lastTime.getTime(); 304 var agoMin = Math.floor(agoMs / 60000); 305 306 // Giorni: conta per calendario (come Tempest), non ore esatte 307 var today = new Date(); today.setHours(0,0,0,0); 308 var lastDay = new Date(lastTime); lastDay.setHours(0,0,0,0); 309 var calendarDays = Math.round((today - lastDay) / 86400000); 310 311 var agoStr = ''; 312 if (agoMin < 1) agoStr = I18N.t('lightning_just_now'); 313 else if (agoMin < 60) agoStr = agoMin + ' min ' + I18N.t('lightning_ago'); 314 else if (calendarDays === 0) { 315 var hrs = Math.floor(agoMin / 60); 316 agoStr = hrs + ' ' + (hrs === 1 ? I18N.t('lightning_hour_ago') : I18N.t('lightning_hours_ago')); 317 } else { 318 agoStr = calendarDays + ' ' + (calendarDays === 1 ? I18N.t('lightning_day_ago') : I18N.t('lightning_days_ago')); 319 } 320 321 if (lCountEl) lCountEl.textContent = agoStr; 322 323 // Dettaglio: distanza + ultimi 3h + oggi 324 var details = []; 325 if (current.lightning.distance) details.push(I18N.t('lightning_distance') + ': ' + current.lightning.distance.toFixed(0) + ' km'); 326 details.push(I18N.t('lightning_last_3hr') + ': ' + (current.lightning.count_3hr || 0)); 327 if (dailyLightning > 0) details.push(I18N.t('rain_today_label') + ': ' + dailyLightning); 328 if (lDetailEl) lDetailEl.textContent = details.join(' | '); 329 } else { 330 // Nessun fulmine mai rilevato 331 if (lCountEl) lCountEl.textContent = '0'; 332 if (lDetailEl) lDetailEl.textContent = I18N.t('lightning_none'); 333 } 334 } 335 336 // --- RADIAZIONE SOLARE + ILLUMINAMENTO --- 337 if (current.uv) { 338 setTextIfExists('solar-rad-value', current.uv.solar_radiation != null ? current.uv.solar_radiation : '--'); 339 var illumEl = document.getElementById('illuminance-value'); 340 if (illumEl && current.illuminance != null) { 341 illumEl.textContent = I18N.t('illuminance_label') + ': ' + current.illuminance.toLocaleStr
341ing() + ' lux'; 342 } 343 } 344 345 // --- DATI AVANZATI --- 346 setTextIfExists('adv-station-pressure', current.station_pressure != null ? current.station_pressure.toFixed(1) : '--'); 347 setTextIfExists('adv-wet-bulb', current.outdoor?.wet_bulb != null ? current.outdoor.wet_bulb.toFixed(1) : '--'); 348 setTextIfExists('adv-delta-t', current.delta_t != null ? current.delta_t.toFixed(1) : '--'); 349 setTextIfExists('adv-air-density', current.air_density != null ? current.air_density.toFixed(4) : '--'); 350 setTextIfExists('adv-wind-chill', current.outdoor?.wind_chill != null ? current.outdoor.wind_chill.toFixed(1) : '--'); 351 352 // Delta T label (per irrorazione agricola) 353 var dtLabel = document.getElementById('adv-delta-t-label'); 354 if (dtLabel && current.delta_t != null) { 355 var dt = current.delta_t; 356 if (dt < 2) dtLabel.textContent = I18N.t('delta_t_too_low'); 357 else if (dt < 5) dtLabel.textContent = I18N.t('delta_t_marginal'); 358 else if (dt <= 8) dtLabel.textContent = I18N.t('delta_t_ideal'); 359 else if (dt <= 10) dtLabel.textContent = I18N.t('delta_t_marginal'); 360 else dtLabel.textContent = I18N.t('delta_t_too_high'); 361 } 362 363 // Batteria 364 if (current.battery != null) { 365 setTextIfExists('adv-battery', current.battery.toFixed(2)); 366 var batStatus = document.getElementById('adv-battery-status'); 367 if (batStatus) { 368 var v = current.battery; 369 if (v >= 2.455) { batStatus.textContent = I18N.t('battery_good'); batStatus.style.color = '#22c55e'; } 370 else if (v >= 2.39) { batStatus.textContent = I18N.t('battery_ok'); batStatus.style.color = '#eab308'; } 371 else { batStatus.textContent = I18N.t('battery_low'); batStatus.style.color = '#ef4444'; } 372 } 373 } 374 375 // WBGT (Wet Bulb Globe Temperature) 376 // Approssimazione: WBGT â 0.7*Twb + 0.2*Tg + 0.1*Ta 377 // Dove Tg (globe) â Ta + 0.005*Rs (radiazione solare) 378 var wbgt = null; 379 var wb = current.outdoor?.wet_bulb; 380 var ta = current.outdoor?.temperature; 381 var rs = current.uv ? current.uv.solar_radiation : 0; 382 if (wb != null && ta != null) { 383 var tg = ta + 0.005 * (rs || 0); // Globe temperature approssimata 384 wbgt = 0.7 * wb + 0.2 * tg + 0.1 * ta; 385 } 386 if (wbgt != null) { 387 setTextIfExists('adv-wbgt', wbgt.toFixed(1)); 388 var wbgtStatus = document.getElementById('adv-wbgt-status'); 389 if (wbgtStatus) { 390 if (wbgt < 25) { wbgtStatus.textContent = I18N.t('wbgt_low'); wbgtStatus.style.color = '#22c55e'; } 391 else if (wbgt < 28) { wbgtStatus.textContent = I18N.t('wbgt_moderate'); wbgtStatus.style.color = '#eab308'; } 392 else if (wbgt < 30) { wbgtStatus.textContent = I18N.t('wbgt_high'); wbgtStatus.style.color = '#f97316'; } 393 else if (wbgt < 32) { wbgtStatus.textContent = I18N.t('wbgt_very_high'); wbgtStatus.style.color = '#ef4444'; } 394 else { wbgtStatus.textContent = I18N.t('wbgt_extreme'); wbgtStatus.style.color = '#dc2626'; } 395 } 396 } 397 398 // Evapotraspirazione ET0 (Penman-Monteith semplificata) 399 // ET0 = (0.408 * Rn + gamma * (900/(T+273)) * u2 * (es - ea)) / (delta + gamma * (1 + 0.34*u2)) 400 // Semplificazione Hargreaves: ET0 â 0.0023 * (Tmean + 17.8) * (Tmax - Tmin)^0.5 * Ra 401 // Ma con i dati Tempest possiamo usare una approssimazione migliore 402 var et0El = document.getElementById('adv-et0'); 403 if (et0El && current.outdoor?.temperature != null && current.uv && current.uv.solar_radiation != null && current.wind) { 404 var T = current.outdoor.temperature; 405 var RH = current.outdoor?.humidity || 50; 406 var Rs = current.uv.solar_radiation; // W/m2 407 var u2 = (current.wind.speed || 0) / 3.6; // km/h to m/s 408 409 // Penman-Monteith FAO-56 semplificata (stima oraria scalata a giornaliera) 410 var es = 0.6108 * Math.exp((17.27 * T) / (T + 237.3)); // kPa 411 var ea = es * RH / 100; 412 var delta = (4098 * es) / Math.pow(T + 237.3, 2); 413 var gamma = 0.0665; // kPa/°C (a ~950 hPa) 414 var Rn = Rs * 0.0036 * 0.75; // conversione W/m2 a MJ/m2/h approssimata * albedo 415 var RnDay = Rn * 12; // approssimazione 12 ore di luce 416 417 var et0 = (0.408 * RnDay * delta + gamma * (900 / (T + 273)) * u2 * (es
417- ea)) / 418 (delta + gamma * (1 + 0.34 * u2)); 419 et0 = Math.max(0, et0); 420 421 et0El.textContent = et0.toFixed(1); 422 var et0Note = document.getElementById('adv-et0-note'); 423 if (et0Note) { 424 if (et0 < 2) et0Note.textContent = I18N.t('et0_low'); 425 else if (et0 < 4) et0Note.textContent = I18N.t('et0_moderate'); 426 else if (et0 < 6) et0Note.textContent = I18N.t('et0_high'); 427 else et0Note.textContent = I18N.t('et0_very_high'); 428 } 429 } 430 431 // Durata pioggia 432 if (current.rain && current.rain.minutes_today != null && current.rain.minutes_today > 0) { 433 var rh = Math.floor(current.rain.minutes_today / 60); 434 var rm = current.rain.minutes_today % 60; 435 setTextIfExists('adv-rain-duration', (rh > 0 ? rh + 'h ' : '') + rm + 'min'); 436 } 437 438 // --- TIMESTAMP --- 439 if (current.timestamp) { 440 var d = new Date(current.timestamp.replace(' ', 'T')); 441 setTextIfExists('data-timestamp', formatDateTime(d)); 442 } 443 } 444 445 // === PREVISIONI 7 GIORNI === 446 function renderForecast(days) { 447 var container = document.getElementById('forecast-container'); 448 if (!container || !days || days.length === 0) return; 449 450 var dayKeys = ['day_sun', 'day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri', 'day_sat']; 451 var today = new Date().toISOString().split('T')[0]; 452 var html = ''; 453 454 days.forEach(function (day) { 455 var d = new Date(day.date + 'T00:00:00'); 456 var isToday = day.date === today; 457 var dayName = isToday ? I18N.t('today') : I18N.t(dayKeys[d.getDay()]); 458 459 html += '<div class="forecast-day' + (isToday ? ' today' : '') + '">' + 460 '<div class="forecast-day-name">' + dayName + '</div>' + 461 '<div class="forecast-icon">' + getWeatherIcon(day.weather_code) + '</div>' + 462 '<div class="forecast-temps">' + 463 '<span class="max">' + convertTemp(day.temp_max) + '</span>' + 464 '<span class="min">' + convertTemp(day.temp_min) + '</span>' + 465 '</div>' + 466 (day.precip_prob > 0 ? '<div class="forecast-precip">' + day.precip_prob + '% ' + I18N.t('precip_rain') + '</div>' : '') + 467 '<div class="forecast-wind">' + Math.round(day.wind_max) + ' km/h</div>' + 468 '</div>'; 469 }); 470 471 container.innerHTML = html; 472 } 473 474 // === PREVISIONI ORARIE === 475 // Riceve daily (con sunrise/sunset per ogni giorno) per determinare 476 // correttamente giorno/notte nelle icone orarie. 477 function renderHourly(hours, daily) { 478 var container = document.getElementById('hourly-container'); 479 if (!container || !hours || hours.length === 0) return; 480 481 var dayNames = I18N.currentLang === 'it' 482 ? ['DOM', 'LUN', 'MAR', 'MER', 'GIO', 'VEN', 'SAB'] 483 : ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; 484 485 var sunByDate = {}; 486 if (daily && Array.isArray(daily)) { 487 daily.forEach(function (d) { 488 if (d.date) { 489 sunByDate[d.date] = { sunrise: d.sunrise, sunset: d.sunset }; 490 } 491 }); 492 } 493 494 var html = ''; 495 var lastDay = -1; 496 hours.forEach(function (h) { 497 var d = new Date(h.time); 498 var hour = String(d.getHours()).padStart(2, '0') + ':00'; 499 var dayOfMonth = d.getDate(); 500 var showDayLabel = dayOfMonth !== lastDay; 501 lastDay = dayOfMonth; 502 503 var dateKey = d.getFullYear() + '-' 504 + String(d.getMonth() + 1).padStart(2, '0') + '-' 505 + String(d.getDate()).padStart(2, '0'); 506 var sun = sunByDate[dateKey] || null; 507 508 var dayName = dayNames[d.getDay()]; 509 var night = isHourNight(d, sun); 510 html += '<div class="hourly-item' + (showDayLabel ? ' new-day' : '') + '">'; 511 if (showDayLabel) { 512 html += '<div class="hourly-day">' + dayName + '</div>'; 513 } 514 html += '<div class="hourly-time">' + hour + '</div>' + 515 '<div class="hourly-icon">' + getWeatherIcon(h.weather_code, night) + '</div>' + 516 '<div class="hourly-temp">' + convertTemp(h.temp) + '</div>' + 517 '</div>'; 518 }); 519 520 container.innerHTML = html; 521 } 522 523 // === QUALITA ARIA === 524 function updateAirQuality(aq) { 525 // AQI principale 526 var aqiEl = document.getElementById('aqi-value'); 527 var labelEl = document.getElementById('aqi-label'); 528 if (aqiEl && aq.aqi !== null) { 529 aqiEl.textContent = aq.aqi; 530 var aqiInfo = getAQIInfo(aq.aqi); 531 aqiEl.className = 'aqi-score ' + aqiInfo.cls; 532 if (labelEl) { 533 labelEl.textContent = aqiInfo.label; 534 labelEl.className = 'aqi-label ' + aqiInfo.cls; 535 } 536 // Posiziona indicatore sulla scala (0-100+) 537 var indicator = document.getElementById('aqi-indicator'); 538 if (indicator) { 539 var pct = Math.min(100, Math.max(0, aq.aqi)); 540 animateBar(indicator, pct, 'left'); 541 } 542 } 543 544 // Inquinanti singoli 545 var pollutants = [ 546 { id: 'pm25', val: aq.pm25, max: 25 }, 547 { id: 'pm10', val: aq.pm10, max: 50 }, 548 { id: 'no2', val: aq.no2, max: 200 }, 549 { id: 'o3', val: aq.o3, max: 180 }, 550 { id: 'so2', val: aq.so2, max: 350 }, 551 { id: 'co', val: aq.co, max: 10000 }, 552 ]; 553
554 pollutants.forEach(function (p) { 555 var valEl = document.getElementById('pol-' + p.id); 556 var barEl = document.getElementById('bar-' + p.id); 557 if (valEl) { 558 if (p.val !== null && p.val !== undefined) { 559 valEl.textContent = p.id === 'co' ? Math.round(p.val) : p.val.toFixed(1); 560 var ratio = Math.min(1, p.val / p.max); 561 if (barEl) { 562 barEl.style.backgroundColor = getPollutantColor(ratio); 563 animateBar(barEl, Math.max(2, ratio * 100)); 564 } 565 } else { 566 valEl.textContent = 'N/D'; 567 } 568 } 569 }); 570 571 // UV (fallback da forecast se la stazione non ha UV) 572 var uvEl = document.getElementById('uv-value'); 573 var uvLabel = document.getElementById('uv-label'); 574 if (uvEl && aq.uv_index !== null && !uvEl._stationUV) { 575 uvEl.textContent = aq.uv_index.toFixed(1); 576 var uvInfo = getUVInfo(aq.uv_index); 577 uvEl.closest('.card-value').className = 'card-value ' + uvInfo.cls; 578 if (uvLabel) uvLabel.textContent = uvInfo.label; 579 } 580 } 581 582 function getPollutantColor(ratio) { 583 if (ratio < 0.25) return '#22c55e'; 584 if (ratio < 0.5) return '#eab308'; 585 if (ratio < 0.75) return '#f97316'; 586 return '#ef4444'; 587 } 588 589 // === POLLINI (Google Pollen API - UPI 0-5) === 590 function updatePollen(pollen) { 591 if (!pollen) return; 592 var types = ['tree', 'grass', 'weed']; 593 types.forEach(function (type) { 594 var valEl = document.getElementById('pollen-' + type); 595 var levelEl = document.getElementById('pollen-' + type + '-level'); 596 var gaugeEl = document.getElementById('pollen-' + type + '-gauge'); 597 var data = pollen[type]; 598 if (!data) { 599 if (valEl) valEl.textContent = '--'; 600 return; 601 } 602 var index = data.index || 0; 603 var info = getPollenInfo(index); 604 if (valEl) { 605 valEl.textContent = index + '/5'; 606 valEl.className = 'pollen-value ' + info.cls; 607 } 608 if (levelEl) { 609 levelEl.textContent = info.label; 610 levelEl.className = 'pollen-level ' + info.cls; 611 } 612 if (gaugeEl) { 613 var pct = (index / 5) * 100; 614 gaugeEl.style.width = pct + '%'; 615 gaugeEl.style.backgroundColor = info.color; 616 } 617 }); 618 } 619 620 function getPollenInfo(index) { 621 // UPI: 0=None, 1=Very Low, 2=Low, 3=Moderate, 4=High, 5=Very High 622 if (index <= 0) return { label: I18N.t('pollen_none'), cls: 'pollen-none', color: '#6b7280' }; 623 if (index <= 1) return { label: I18N.t('pollen_very_low'), cls: 'pollen-low', color: '#22c55e' }; 624 if (index <= 2) return { label: I18N.t('pollen_low'), cls: 'pollen-low', color: '#84cc16' }; 625 if (index <= 3) return { label: I18N.t('pollen_moderate'), cls: 'pollen-moderate', color: '#eab308' }; 626 if (index <= 4) return { label: I18N.t('pollen_high'), cls: 'pollen-high', color: '#f97316' }; 627 return { label: I18N.t('pollen_very_high'), cls: 'pollen-very-high', color: '#ef4444' }; 628 } 629 630 // === ALBA / TRAMONTO === 631 function updateSunData(sun) { 632 var sr, ss; 633 if (sun.sunrise) { 634 sr = new Date(sun.sunrise); 635 setTextIfExists('sunrise-time', sr.getHours() + ':' + String(sr.getMinutes()).padStart(2, '0')); 636 } 637 if (sun.sunset) { 638 ss = new Date(sun.sunset); 639 setTextIfExists('sunset-time', ss.getHours() + ':' + String(ss.getMinutes()).padStart(2, '0')); 640 } 641 if (sun.daylight_hours) { 642 var h = Math.floor(sun.daylight_hours); 643 var m = Math.round((sun.daylight_hours - h) * 60); 644 setTextIfExists('daylight-hours', h + 'h ' + m + 'min ' + I18N.t('daylight_suffix')); 645 } 646 647 // Indicatore lineare posizione sole 648 if (sr && ss) { 649 var now = Date.now(); 650 var sunriseMs = sr.getTime();
651 var sunsetMs = ss.getTime(); 652 var totalDay = sunsetMs - sunriseMs; 653 var elapsed = now - sunriseMs; 654 var pct = Math.max(0, Math.min(100, (elapsed / totalDay) * 100)); 655 656 var fill = document.getElementById('sun-progress-fill'); 657 var dot = document.getElementById('sun-progress-dot'); 658 659 if (fill) fill.style.width = pct + '%'; 660 if (dot) { 661 dot.style.left = pct + '%'; 662 // Di notte: pallino grigio 663 if (now < sunriseMs || now > sunsetMs) { 664 dot.classList.add('night'); 665 } else { 666 dot.classList.remove('night'); 667 } 668 } 669 } 670 } 671 672 // === GRAFICO 24H === 673 function updateChart(history, range) { 674 if (!history || history.length === 0) return; 675 if (range === undefined) range = currentChartRange; 676 677 var canvas = document.getElementById('weather-chart'); 678 if (!canvas) return; 679 680 var ctx = canvas.getContext('2d'); 681 var labels = []; 682 var dataset1 = []; 683 684 var config = getChartConfig(currentChartType); 685
686 history.forEach(function (point) { 687 labels.push(formatChartLabel(point.time, range)); 688 dataset1.push(point[config.field1]); 689 }); 690 691 var datasets = [{ 692 label: config.label1, 693 data: dataset1, 694 borderColor: config.color1, 695 backgroundColor: config.color1 + '15', 696 borderWidth: 2, 697 fill: true, 698 tension: 0.4, 699 pointRadius: 0, 700 pointHitRadius: 10, 701 }]; 702 703 if (chart) { 704 chart.data.labels = labels; 705 chart.data.datasets = datasets; 706 chart.options.scales.y.title.text = config.yLabel; 707 chart.update('none'); 708 } else { 709 var tc = getChartThemeColors(); 710 chart = new Chart(ctx, { 711 type: 'line', 712 data: { labels: labels, datasets: datasets }, 713 options: { 714 responsive: true, 715 maintainAspectRatio: false, 716 interaction: { mode: 'index', intersect: false }, 717 plugins: { 718 legend: { display: false }, 719 tooltip: { 720 backgroundColor: tc.tooltipBg, 721 titleColor: tc.tooltipTitle, 722 bodyColor: tc.tooltipBody, 723 borderColor: tc.tooltipBorder, 724 borderWidth: 1, 725 padding: 12, 726 cornerRadius: 10, 727 } 728 }, 729 scales: { 730 x: { 731 grid: { color: tc.gridColor, drawBorder: false }, 732 ticks: { color: tc.tickColor, maxTicksLimit: 12, font: { size: 11 } } 733 }, 734 y: { 735 grid: { color: tc.gridColor, drawBorder: false }, 736 ticks: { color: tc.tickColor, font: { size: 11 } }, 737 title: { display: true, text: config.yLabel, color: tc.tickColor } 738 } 739 } 740 } 741 }); 742 } 743 } 744 745 function getChartConfig(type) { 746 var unit = useFahrenheit ? '\u00b0F' : '\u00b0C'; 747 var configs = { 748 temperature: { 749 field1: 'temp_out', label1: I18N.t('chart_temp_label'), 750 color1: '#3b82f6', yLabel: I18N.t('chart_temp_label') + ' (' + unit + ')' 751 }, 752 humidity: { 753 field1: 'humidity_out', label1: I18N.t('chart_hum_label'), 754 color1: '#06b6d4', yLabel: I18N.t('chart_hum_label') + ' (%)' 755 }, 756 pressure: { 757 field1: 'pressure', label1: I18N.t('chart_press_label'), 758 color1: '#a855f7', yLabel: I18N.t('chart_press_label') + ' (hPa)' 759 } 760 }; 761 return configs[type] || configs.temperature; 762 } 763 764 // === WEATHER CODE -> METEOCONS SVG (stesso stile app) === 765 function getWeatherIconFile(code, isNight) { 766 if (code === null || code === undefined) return 'overcast-day'; 767 switch (code) { 768 case 0: return isNight ? 'clear-night' : 'clear-day'; 769 case 1: return isNight ? 'clear-night' : 'clear-day'; 770 case 2: return isNight ? 'partly-cloudy-night' : 'partly-cloudy-day'; 771 case 3: return isNight ? 'overcast-night' : 'overcast-day'; 772 case 45: case 48: return isNight ? 'fog-night' : 'fog-day'; 773 case 51: case 53: case 55: return 'drizzle';
774 case 56: case 57: case 66: case 67: return 'sleet'; 775 case 61: case 63: case 80: case 81: return 'rain'; 776 case 65: case 82: return 'extreme-rain'; 777 case 71: case 73: case 85: return 'snow'; 778 case 75: case 77: case 86: return 'extreme-snow'; 779 case 95: return isNight ? 'thunderstorms-night' : 'thunderstorms-day'; 780 case 96: case 99: return 'thunderstorms-extreme-rain'; 781 default: return isNight ? 'overcast-night' : 'overcast-day'; 782 } 783 } 784 function getWeatherIcon(code, isNight) { 785 var name = getWeatherIconFile(code, isNight); 786 return '<img class="wx-icon" src="assets/icons/weather/' + name + '.svg" alt="" loading="lazy">'; 787 } 788 789 // === WEATHER CODE -> EMOJI (fallback testuale) === 790 function getWeatherEmoji(code, isNight) { 791 if (code === null || code === undefined) return '\u2753'; 792 var dayMap = { 793 // code 1 "prev sereno" -> stessa icona di "sereno" (allineato con app) 794 0: '\u2600\ufe0f', 1: '\u2600\ufe0f', 2: '\u26c5', 3: '\u2601\ufe0f', 795 45: '\ud83c\udf2b\ufe0f', 48: '\ud83c\udf2b\ufe0f', 796 51: '\ud83c\udf26\ufe0f', 53: '\ud83c\udf26\ufe0f', 55: '\ud83c\udf26\ufe0f', 797 56: '\ud83c\udf28\ufe0f', 57: '\ud83c\udf28\ufe0f', 798 61: '\ud83c\udf27\ufe0f', 63: '\ud83c\udf27\ufe0f', 65: '\ud83c\udf27\ufe0f', 799 66: '\ud83c\udf28\ufe0f', 67: '\ud83c\udf28\ufe0f', 800 71: '\ud83c\udf28\ufe0f', 73: '\u2744\ufe0f', 75: '\u2744\ufe0f', 77: '\u2744\ufe0f', 801 80: '\ud83c\udf26\ufe0f', 81: '\ud83c\udf27\ufe0f', 82: '\ud83c\udf27\ufe0f', 802 85: '\ud83c\udf28\ufe0f', 86: '\ud83c\udf28\ufe0f', 803 95: '\u26c8\ufe0f', 96: '\u26c8\ufe0f', 99: '\u26c8\ufe0f', 804 }; 805 if (isNight) { 806 if (code === 0) return '\ud83c\udf19'; // ð 807 if (code === 1) return '\ud83c\udf19'; // ð 808 if (code === 2) return '\u2601\ufe0f\ud83c\udf19'; // âï¸ð 809 } 810 return dayMap[code] || '\u2601\ufe0f'; 811 } 812 813 function isHourNight(hourDate, sunData) { 814 var hr = hourDate.getHours(); 815 if (sunData && sunData.sunrise && sunData.sunset) { 816 var sr = parseHHMM(sunData.sunrise); 817 var ss = parseHHMM(sunData.sunset); 818 var min = hourDate.getHours() * 60 + hourDate.getMinutes(); 819 if (sr !== null && ss !== null) { 820 return min < sr || min >= ss; 821 } 822 } 823 return hr < 6 || hr >= 20; 824 } 825 function parseHHMM(s) { 826 if (!s || typeof s !== 'string') return null; 827 var m = s.match(/(\d{1,2}):(\d{2})/); 828 if (!m) return null; 829 return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); 830 } 831 832 // === TEMPERATURA MARE === 833 function updateSeaTemp(sea) { 834 var el = document.getElementById('sea-temp-value'); 835 if (el && sea.temperature !== null && sea.temperature !== undefined) { 836 el.textContent = parseFloat(sea.temperature).toFixed(1); 837 } 838 } 839 840 // === FASE LUNARE (canvas raytraced identico a leccemeteo) === 841 var LUNAR_CYCLE = 29.530588853; 842 843 function updateMoonPhase() { 844 var now = new Date(); 845 var age = getMoonAge(now); 846 var illumination = getMoonIllumination(age); 847 var label = getMoonLabel(age); 848 849 var labelEl = document.getElementById('moon-label'); 850 var illumEl = document.getElementById('moon-illumination'); 851 var ageEl = document.getElementById('moon-age'); 852 853 if (labelEl) labelEl.textContent = label; 854 if (illumEl) illumEl.textContent = illumination.toFixed(1) + '%'; 855 if (ageEl) ageEl.textContent = age.toFixed(1) + ' ' + I18N.t('moon_days'); 856 857 renderMoonCanvas(age); 858 } 859 860 function getMoonAge(date) { 861 var refNewMoon = new Date(Date.UTC(2000, 0, 6, 18, 14, 0)); 862 var diff = (date - refNewMoon) / (1000 * 60 * 60 * 24); 863 return ((diff % LUNAR_CYCLE) + LUNAR_CYCLE) % LUNAR_CYCLE; 864 } 865 866 function getMoonIllumination(age) { 867 return (1 - Math.cos(2 * Math.PI * age / LUNAR_CYCLE)) / 2 * 100; 868 } 869 870 function getMoonLabel(age) { 871 if (age < 1.85) return I18N.t('moon_new'); 872 if (age < 7.38) return I18N.t('moon_waxing_crescent'); 873 if (age < 9.22) return I18N.t('moon_first_quarter'); 874 if (age < 14.77) return I18N.t('moon_waxing_gibbous'); 875 if (age < 16.61) return I18N.t('moon_full'); 876 if (age < 22.15) return I18N.t('moon_waning_gibbous'); 877 if (age < 23.99) return I18N.t('moon_last_quarter'); 878 return I18N.t('moon_waning_crescent'); 879 } 880 881 function renderMoonCanvas(age) { 882 var canvas = document.getElementById('moon-canvas'); 883 if (!canvas) return; 884 var ctx = canvas.getContext('2d'); 885 var W = canvas.width, H = canvas.height; 886 var cx = W / 2, cy = H / 2, R = W / 2 - 4; 887 888 var isLight = document.documentElement.getAttribute('data-theme') === 'light'; 889 890 var phaseAngle = 2 * Math.PI * age / LUNAR_CYCLE; 891 var lightX = Math.sin(phaseAngle);
892 var lightZ = -Math.cos(phaseAngle); 893 894 function hash(x, y) { 895 var h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; 896 return h - Math.floor(h); 897 } 898 function noise(x, y) { 899 var ix = Math.floor(x), iy = Math.floor(y); 900 var fx = x - ix, fy = y - iy; 901 var a = hash(ix, iy), b = hash(ix + 1, iy); 902 var c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1); 903 var ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy); 904 return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy; 905 } 906 function fbm(x, y) { 907 var v = 0, amp = 0.5, freq = 1; 908 for (var i = 0; i < 5; i++) { 909 v += amp * noise(x * freq, y * freq); 910 amp *= 0.5; freq *= 2; 911 } 912 return v; 913 } 914 915 var imgData = ctx.createImageData(W, H); 916 var data = imgData.data; 917 918 for (var py = 0; py < H; py++) { 919 for (var px = 0; px < W; px++) { 920 var dx = (px - cx) / R, dy = (py - cy) / R; 921 var dist2 = dx * dx + dy * dy; 922 var idx = (py * W + px) * 4; 923 924 if (dist2 > 1) { 925 data[idx] = data[idx + 1] = data[idx + 2] = 0; 926 data[idx + 3] = 0; 927 continue; 928 } 929 930 var dz = Math.sqrt(1 - dist2); 931 var light = dx * lightX + dz * lightZ; 932 light = Math.max(0, light); 933 934 var u = Math.atan2(dx, dz) / Math.PI * 4 + 2; 935 var v = Math.asin(Math.max(-1, Math.min(1, dy))) / Math.PI * 4 + 2; 936 937 var surface = fbm(u * 3, v * 3); 938 var baseColor = isLight ? 180 + surface * 50 : 155 + surface * 70; 939 var maria = fbm(u * 1.5 + 10, v * 1.5 + 10); 940 if (maria > 0.55) baseColor *= (isLight ? 0.8 : 0.7); 941 var craters = fbm(u * 8, v * 8); 942 if (craters > 0.7) baseColor *= 0.85; 943 944 var sunLight = Math.max(0, light); 945 var ambient = isLight ? 0.38 : 0.25; 946 var limb = 1 - Math.pow(1 - dz, 2) * 0.2; 947 var term = sunLight > 0.01 ? Math.pow(sunLight, 0.5) : 0; 948 var brightness = (term * 0.65 + ambient) * limb; 949 950 var r = baseColor * brightness; 951 var g = (baseColor - 2) * brightness; 952 var bl = baseColor * brightness * (sunLight < 0.15 ? 1.1 : 1); 953 954 data[idx] = Math.min(255, Math.max(0, r)); 955 data[idx + 1] = Math.min(255, Math.max(0, g)); 956 data[idx + 2] = Math.min(255, Math.max(0, bl)); 957 data[idx + 3] = 255; 958 } 959 } 960 961 ctx.putImageData(imgData, 0, 0); 962 963 ctx.save(); 964 ctx.strokeStyle = isLight ? 'rgba(80,80,100,0.3)' : 'rgba(180,190,210,0.15)'; 965 ctx.lineWidth = isLight ? 1.5 : 1; 966 ctx.beginPath(); 967 ctx.arc(cx, cy, R, 0, Math.PI * 2); 968 ctx.stroke(); 969 ctx.restore(); 970 } 971 972 // === RECORD STORICI === 973 function loadRecords() { 974 fetch(RECORDS_URL) 975 .then(function (r) { return r.json(); }) 976 .then(function (data) { renderRecords(data); }) 977 .catch(function (err) { console.error('Errore record:', err); }); 978 } 979 980 function renderRecords(data) { 981 function setRecord(idVal, idDate, record) { 982 var elVal = document.getElementById(idVal); 983 var elDate = document.getElementById(idDate); 984 if (!record) return; 985 if (elVal) elVal.textContent = record.value; 986 if (elDate && record.date) { 987 var d = new Date(record.date.replace(' ', 'T')); 988 elDate.textContent = formatDateTime(d); 989 } 990 } 991 if (data.temperature) { 992 setRecord('rec-temp-max', 'rec-temp-max-date', data.temperature.max); 993 setRecord('rec-temp-min', 'rec-temp-min-date', data.temperature.min); 994 } 995 if (data.humidity) { 996 setRecord('rec-hum-max', 'rec-hum-max-date', data.humidity.max); 997 setRecord('rec-hum-min', 'rec-hum-min-date', data.humidity.min); 998 } 999 if (data.pressure) { 1000 setRecord('rec-press-max', 'rec-press-max-date', data.pressure.max); 1001 setRecord('rec-press-min', 'rec-press-min-date', data.pressure.min); 1002 } 1003 if (data.wind_gust && data.wind_gust.max) { 1004 setRecord('rec-gust-max', 'rec-gust-max-date', data.wind_gust.max); 1005 } 1006 if (data.uv_index && data.uv_index.max) { 1007 setRecord('rec-uv-max', 'rec-uv-max-date', data.uv_index.max); 1008 } 1009 var meta = document.getElementById('records-meta'); 1010 if (meta && data.total_measurements && data.first_record) { 1011 var since = new Date(data.first_record.replace(' ', 'T')); 1012 meta.textContent = I18N.t('records_since') + ' ' + formatDateTime(since) 1013 + ' \u2014 ' + data.total_measurements.toLocaleStr
1013ing() + ' ' + I18N.t('records_measurements'); 1014 } 1015 } 1016 1017 // === STATISTICHE (oggi l'anno scorso + estremi mensili) === 1018 function loadStats() { 1019 fetch(STATS_URL) 1020 .then(function (r) { return r.json(); }) 1021 .then(function (data) { 1022 if (data.last_year) renderLastYear(data.last_year); 1023 if (data.monthly_extremes && data.monthly_extremes.length > 0) renderMonthlyExtremes(data.monthly_extremes); 1024 }) 1025 .catch(function (err) { console.error('Errore stats:', err); }); 1026 } 1027 1028 function renderLastYear(ly) { 1029 var section = document.getElementById('last-year-section'); 1030 var container = document.getElementById('last-year-container'); 1031 if (!section || !container) return; 1032 1033 section.style.display = ''; 1034 container.style.display = ''; 1035 1036 setTextIfExists('ly-avg-temp', ly.avg_temp.toFixed(1)); 1037 setTextIfExists('ly-avg-hum', ly.avg_hum); 1038 setTextIfExists('ly-avg-press', ly.avg_press.toFixed(1)); 1039 1040 var rangeEl = document.getElementById('ly-temp-range'); 1041 if (rangeEl) { 1042 rangeEl.textContent = 'Min ' + ly.min_temp + '\u00b0 / Max ' + ly.max_temp + '\u00b0'; 1043 } 1044 1045 var dateEl = document.getElementById('last-year-date'); 1046 if (dateEl) { 1047 var d = new Date(ly.date + 'T00:00:00'); 1048 dateEl.textContent = I18N.t('ly_date_label') + ' ' + formatDateTime(d).split(' ').slice(0, 2).join(' '); 1049 } 1050 } 1051 1052 function renderMonthlyExtremes(months) { 1053 var section = document.getElementById('monthly-section'); 1054 var container = document.getElementById('monthly-container'); 1055 var tbody = document.getElementById('monthly-tbody'); 1056 if (!section || !container || !tbody) return; 1057 1058 section.style.display = ''; 1059 container.style.display = ''; 1060 1061 var html = ''; 1062 months.forEach(function (m) { 1063 var monthName = I18N.t('month_' + m.month); 1064 var label = m.year ? monthName + ' ' + m.year : monthName; 1065 html += '<tr>' + 1066 '<td>' + label + '</td>' + 1067 '<td class="temp-max">' + m.max_temp + '</td>' + 1068 '<td class="temp-min">' + m.min_temp + '</td>' + 1069 '<td>' + m.avg_temp + '</td>' + 1070 '<td>' + m.avg_hum + '</td>' + 1071 '</tr>'; 1072 }); 1073 tbody.innerHTML = html; 1074 } 1075 1076 // === PUNTO DI RUGIADA === 1077 // Formula di Magnus 1078 function calcDewPoint(T, RH) { 1079 var a = 17.625, b = 243.04; 1080 var alpha = (a * T) / (b + T) + Math.log(RH / 100); 1081 return (b * alpha) / (a - alpha); 1082 } 1083 1084 function getDewPointLabel(dp) { 1085 if (dp < 0) return I18N.t('dp_dry'); 1086 if (dp < 10) return I18N.t('dp_comfortable'); 1087 if (dp < 16) return I18N.t('dp_pleasant'); 1088 if (dp < 21) return I18N.t('dp_humid'); 1089 if (dp < 25) return I18N.t('dp_very_humid'); 1090 return I18N.t('dp_oppressive'); 1091 } 1092 1093 // === TEMPERATURA PERCEPITA === 1094 // Usa apparent_temperature da Open-Meteo (tiene conto di vento, umidita, radiazione) 1095 var _omApparentTemp = null; 1096 1097 function updateFeelsLikeFromOM(currentOM) { 1098 _omApparentTemp = currentOM.apparent_temperature; 1099 // Aggiorna subito se i dati della stazione sono gia caricati 1100 var heroTemp = document.getElementById('hero-temp-value'); 1101 if (heroTemp && heroTemp.textContent !== '--') { 1102 var temp = parseFloat(heroTemp.textContent.replace(',', '.')); 1103 if (!isNaN(temp)) { 1104 var fl = _omApparentTemp; 1105 setTextIfExists('feels-like-value', fl.toFixed(1)); 1106 var flLabel = document.getElementById('feels-like-label'); 1107 if (flLabel) flLabel.textContent = getFeelsLikeLabel(temp, fl); 1108 } 1109 } 1110 } 1111 1112 function calcFeelsLike(T, RH) { 1113 // Se abbiamo la percepita da Open-Meteo, usala (piu precisa) 1114 if (_omApparentTemp !== null) return _omApparentTemp; 1115 // Fallback: Heat Index per temperature alte 1116 if (T >= 27 && RH >= 40) { 1117 return -8.78469475556 1118 + 1.61139411 * T 1119 + 2.33854883889 * RH 1120 - 0.14611605 * T * RH 1121 - 0.012308094 * T * T 1122 - 0.0164248277778 * RH * RH 1123 + 0.002211732 * T * T * RH 1124 + 0.00072546 * T * RH * RH 1125 - 0.000003582 * T * T * RH * RH; 1126 } 1127 return T; 1128 } 1129 1130 function getFeelsLikeLabel(T, feelsLike) { 1131 var diff = feelsLike - T; 1132 if (Math.abs(diff) < 0.5) return I18N.t('fl_same'); 1133 if (diff > 0) return I18N.t('fl_warmer'); 1134 return I18N.t('fl_cooler'); 1135 } 1136 1137 // === AQI INFO === 1138 function getAQIInfo(aqi) { 1139 // European AQI: 0-20 Good, 21-40 Fair, 41-60 Moderate, 61-80 Poor, 81-100 Very Poor 1140 if (aqi <= 20) return { label: I18N.t('aqi_good'), cls: 'aqi-good' }; 1141 if (aqi <= 40) return { label: I18N.t('aqi_fair'), cls: 'aqi-fair' }; 1142 if (aqi <= 60) return { label: I18N.t('aqi_moderate'), cls: 'aqi-moderate' }; 1143 if (aqi <= 80) return { label: I18N.t('aqi_poor'), cls: 'aqi-poor' }; 1144 return { label: I18N.t('aqi_very_poor'), cls: 'aqi-very-poor' }; 1145 } 1146 1147 // === UV INFO === 1148 function getUVInfo(uv) { 1149 if (uv < 3) return { label: I18N.t('uv_low'), cls: 'uv-low' }; 1150 if (uv < 6) return { label: I18N.t('uv_moderate'), cls: 'uv-moderate' }; 1151 if (uv < 8) return { label: I18N.t('uv_high'), cls: 'uv-high' }; 1152 if (uv < 11) return { label: I18N.t('uv_very_high'), cls: 'uv-very-high' }; 1153 return { label: I18N.t('uv_extreme'), cls: 'uv-extreme' }; 1154 } 1155 1156 // === HELPERS === 1157 function setTextIfExists(id, value) { 1158 var el = document.getElementById(id); 1159 if (el) el.textContent = value ?? '--'; 1160 } 1161 1162 function animateBar(el, targetPct, prop) { 1163 prop = prop || 'width'; 1164 if (!el.dataset.animated) { 1165 el.style[prop] = '0%'; 1166 el.dataset.animated = '1'; 1167 el.offsetWidth; // Forza reflow 1168 el.style.transition = prop + ' 1s cubic-bezier(0.4, 0, 0.2, 1)'; 1169 } 1170 el.style[prop] = targetPct + '%'; 1171 } 1172 1173 function convertTemp(val) { 1174 if (val === null || val === undefined) return '--'; 1175 var v = parseFloat(val); 1176 if (useFahrenheit) v = v * 9 / 5 + 32; 1177 return Math.round(v) + '\u00b0'; 1178 } 1179 1180 function formatTemp(val) { 1181 if (val === null || val === undefined) return '--'; 1182 var v = parseFloat(val); 1183 if (useFahrenheit) { 1184 v = v * 9 / 5 + 32; 1185 return v.toFixed(1) + '\u00b0F'; 1186 } 1187 return v.toFixed(1) + '\u00b0C'; 1188 } 1189 1190 function formatDateTime(d) { 1191 var dayFullKeys = ['day_sun_full', 'day_mon_full', 'day_tue_full', 'day_wed_full', 'day_thu_full', 'day_fri_full', 'day_sat_full']; 1192 var day = String(d.getDate()).padStart(2, '0');
1193 var month = String(d.getMonth() + 1).padStart(2, '0'); 1194 var year = String(d.getFullYear()).slice(-2); 1195 return I18N.t(dayFullKeys[d.getDay()]) + ' ' + 1196 day + '/' + month + '/' + year + ' ' + 1197 d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); 1198 } 1199 1200 function getTrendClass(trend) { 1201 if (trend === 'up') return 'trend-up'; 1202 if (trend === 'down') return 'trend-down'; 1203 return 'trend-stable'; 1204 } 1205 1206 function getTrendIcon(trend) { 1207 if (trend === 'up') return '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7 17 9.2-9.2M17 17V7H7"/></svg>'; 1208 if (trend === 'down') return '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7l9.2 9.2M17 7v10H7"/></svg>'; 1209 return '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>'; 1210 } 1211 1212 function getWindDirection(angle) { 1213 var dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSO', 'SO', 'OSO', 'O', 'ONO', 'NO', 'NNO']; 1214 return dirs[Math.round(angle / 22.5) % 16]; 1215 } 1216 1217 function buildWindCard(wind) { 1218 var html = '<div class="card-label">' + I18N.t('wind') + '</div>' + 1219 '<div class="card-value">' + wind.speed.toFixed(1) + ' <span class="unit">km/h</span></div>' + 1220 '<div class="card-detail">' + I18N.t('wind_direction') + ': ' + getWindDirection(wind.angle); 1221 if (wind.gust) { 1222 html += ' | ' + I18N.t('wind_gust_label') + ': ' + wind.gust.toFixed(1) + ' km/h'; 1223 } 1224 if (wind.lull !== null && wind.lull !== undefined) { 1225 html += ' | ' + I18N.t('wind_lull_label') + ': ' + wind.lull.toFixed(1) + ' km/h'; 1226 } 1227 html += '</div>'; 1228 return html; 1229 } 1230 1231 function getRainIntensity(rain) { 1232 // Usa precip istantaneo (mm/min â mm/h) come principale. 1233 // Fallback: mm degli ultimi ~15 min (campo 'recent', delta cumulato) â mm/h. 1234 // Reattivo in entrambe le direzioni: rileva la pioggia intermittente ma non 1235 // resta "pioggia leggera" fino a un'ora dopo che ha smesso (difetto di 1236 // last_1hr). Se 'recent' manca (payload vecchio), fallback su last_1hr. 1237 var instantMmh = (rain.current || 0) * 60; 1238 var recentMmh = rain.recent != null 1239 ? rain.recent * 4 1240 : (rain.last_1hr != null ? rain.last_1hr : 0); 1241 1242 var mmh = instantMmh > 0.05 ? instantMmh : recentMmh; 1243 var isActive = instantMmh > 0.05; 1244 1245 // Soglie allineate alla stazione Tempest WeatherFlow: 1246 // None: 0, Light: < 1, Moderate: 1-4, Heavy: 4-16, Very Heavy: > 16 1247 if (mmh <= 0) return { label: I18N.t('rain_no_rain'), color: '', rate: 0, active: false }; 1248 if (mmh < 1) return { label: I18N.t('rain_light'), color: '#22c55e', rate: mmh, active: isActive }; 1249 if (mmh < 4) return { label: I18N.t('rain_moderate'), color: '#eab308', rate: mmh, active: isActive }; 1250 if (mmh < 16) return { label: I18N.t('rain_heavy'), color: '#f97316', rate: mmh, active: isActive }; 1251 return { label: I18N.t('rain_very_heavy'), color: '#ef4444', rate: mmh, active: isActive }; 1252 } 1253 1254 function buildRainCard(rain) { 1255 var dayVal = rain.day != null ? rain.day.toFixed(1) : '0.0'; 1256 var hourVal = rain.last_1hr != null ? rain.last_1hr : (rain.hour != null ? rain.hour : null); 1257 1258 // Intensita' pioggia 1259 var intensity = getRainIntensity(rain); 1260 var intensityHtml = ''; 1261 if (intensity.rate > 0) { 1262 var dotStyle = intensity.active 1263 ? 'display:inline-block;width:8px;height:8px;border-radius:50%;background:' + intensity.color + ';margin-right:0.4rem;animation:pulse-rain 1.5s ease-in-out infinite;' 1264 : 'display:inline-block;width:8px;height:8px;border-radius:50%;background:' + intensity.color + ';margin-right:0.4rem;opacity:0.6;'; 1265 intensityHtml = '<div class="rain-intensity" style="margin-top:0.3rem;display:flex;align-items:center;">' + 1266 '<span style="' + dotStyle + '"></span>' + 1267 '<span style="color:' + intensity.color + ';font-weight:600;font-size:0.85rem;">' + intensity.label + '</span>' + 1268 '<span style="color:var(--text-muted);font-size:0.75rem;margin-left:0.4rem;">(' + intensity.rate.toFixed(1) + ' mm/h)</span>' + 1269 '</div>'; 1270 } 1271 1272 // Dettagli: 1h | Oggi | Ieri | Durata 1273 var details = []; 1274 if (hourVal != null && hourVal > 0) details.push(I18N.t('rain_1h_label') + ': ' + parseFloat(hourVal).toFixed(1) + ' mm'); 1275 details.push(I18N.t('rain_today_label') + ': ' + dayVal + ' mm'); 1276 if (rain.yesterday != null && rain.yesterday > 0) { 1277 details.push(I18N.t('rain_yesterday_label') + ': ' + rain.yesterday.toFixed(1) + ' mm'); 1278 } 1279 if (rain.minutes_today != null && rain.minutes_today > 0) { 1280 var h = Math.floor(rain.minutes_today / 60); 1281 var m = rain.minutes_today % 60; 1282 details.push((h > 0 ? h + 'h ' : '') + m + 'min ' + I18N.t('rain_duration')); 1283 } 1284 1285 return '<div class="card-label"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewB
1285ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 14v6"/><path d="M8 14v6"/><path d="M12 16v6"/></svg> ' + I18N.t('rain') + '</div>' + 1286 '<div class="card-value">' + dayVal + ' <span class="unit">mm</span></div>' + 1287 intensityHtml + 1288 '<div class="card-detail">' + details.join(' | ') + '</div>'; 1289 } 1290 1291 // === MOBILE MAP GESTURES === 1292 function initMobileMapGestures() { 1293 if (!('ontouchstart' in window)) return; 1294 1295 // Overlay iframe: tap per attivare, 5s timeout per disattivare 1296 document.querySelectorAll('.iframe-overlay').forEach(function (overlay) { 1297 overlay.addEventListener('click', function () { 1298 overlay.classList.add('active'); 1299 clearTimeout(overlay._timer); 1300 overlay._timer = setTimeout(function () { 1301 overlay.classList.remove('active'); 1302 }, 5000); 1303 }); 1304 }); 1305 } 1306 1307 function updateLastRefresh() { 1308 var el = document.getElementById('refresh-time'); 1309 if (el) { 1310 var now = new Date(); 1311 el.textContent = I18N.t('updated_at') + ' ' + now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0'); 1312 } 1313 } 1314 1315 // === SHARE BUTTON === 1316 // Flusso: 1317 // 1) Fetch PNG generato da api/share_image.php (stesso renderer IG/FB) 1318 // 2) Se browser supporta navigator.share con file -> share sheet nativo 1319 // 3) Se supporta solo navigator.share testo -> share testo + download immagine 1320 // 4) Altrimenti -> modal con anteprima immagine, download, copy testo 1321 function initShareBtn() { 1322 var btn = document.getElementById('share-btn'); 1323 if (!btn) return; 1324 btn.addEventListener('click', function () { 1325 shareWithImage(btn); 1326 }); 1327 } 1328 1329 function setShareButtonState(btn, state) { 1330 var label = document.getElementById('share-btn-label'); 1331 if (state === 'loading') { 1332 btn.classList.add('loading'); 1333 if (label) label.textContent = I18N.t('share_loading') || '...'; 1334 } else if (state === 'copied') { 1335 btn.classList.remove('loading'); 1336 btn.classList.add('copied'); 1337 if (label) label.textContent = I18N.t('share_copied'); 1338 setTimeout(function () { setShareButtonState(btn, 'idle'); }, 2500); 1339 } else { 1340 btn.classList.remove('loading'); 1341 btn.classList.remove('copied'); 1342 if (label) label.textContent = I18N.t('share'); 1343 } 1344 } 1345 1346 // Testo BREVE usato quando si condivide con immagine (i dati sono gia' 1347 // visibili nell'immagine, quindi ridondante ripeterli). 1348 function buildShareShortText() { 1349 return 'meteopomarico.it'; 1350 } 1351 1352 function shareWithImage(btn) { 1353 var fullText = buildShareText(); 1354 var shortText = buildShareShortText(); 1355 setShareButtonState(btn, 'loading'); 1356 1357 fetch('api/share_image.php', { credentials: 'same-origin' }) 1358 .then(function (res) { 1359 if (!res.ok) throw new Error('HTTP ' + res.status); 1360 return res.blob(); 1361 }) 1362 .then(function (blob) { 1363 var fileName = 'meteopomarico-' + Date.now() + '.png'; 1364 var file = new File([blob], fileName, { type: 'image/png' }); 1365 1366 if (!navigator.share) { 1367 openShareModal(blob, fullText, shortText, btn); 1368 return; 1369 } 1370 1371 // Con immagine: usa testo breve (dettagli gia' nell'immagine). 1372 navigator.share({ text: shortText, files: [file] }) 1373 .then(function () { setShareButtonState(btn, 'idle'); }) 1374 .catch(function (err) { 1375 if (err && err.name === 'AbortError') { 1376 setShareButtonState(btn, 'idle'); 1377 return; 1378 } 1379 // Browser non accetta file -> share testo completo 1380 navigator.share({ text: fullText }) 1381 .then(function () { setShareButtonState(btn, 'idle'); }) 1382 .catch(function (err2) { 1383 if (err2 && err2.name === 'AbortError') { 1384 setShareButtonState(btn, 'idle'); 1385 return; 1386 } 1387 openShareModal(blob, fullText, shortText, btn); 1388 }); 1389 }); 1390 }) 1391 .catch(function (err) { 1392 console.error('Share fetch error:', err); 1393 setShareButtonState(btn, 'idle'); 1394 if (navigator.clipboard) { 1395 navigator.clipboard.writeText(buildShareText()) 1396 .then(function () { setShareButtonState(btn, 'copied'); }); 1397 } 1398 }); 1399 } 1400 1401 function openShareModal(blob, fullText, shortText, btn) { 1402 setShareButtonState(btn, 'idle'); 1403 var url = URL.createObjectURL(blob); 1404 1405 var canCopyImage = !!(navigator.clipboard && window.ClipboardItem); 1406
1407 var overlay = document.createElement('div'); 1408 overlay.className = 'share-modal-overlay'; 1409 var copyImgBtn = canCopyImage 1410 ? '<button class="share-modal-btn primary" data-action="copy-image">' + 1411 (I18N.t('share_copy_image') || 'Copia immagine') + 1412 '</button>' 1413 : ''; 1414 overlay.innerHTML = 1415 '<div class="share-modal">' + 1416 '<button class="share-modal-close" aria-label="Chiudi">×</button>' + 1417 '<img class="share-modal-img" src="' + url + '" alt="">' + 1418 '<div class="share-modal-text"></div>' + 1419 '<div class="share-modal-actions">' + 1420 copyImgBtn + 1421 '<a class="share-modal-btn" download="meteopomarico-' + Date.now() + '.png" href="' + url + '">' + 1422 (I18N.t('share_download') || 'Scarica') + 1423 '</a>' + 1424 '<button class="share-modal-btn" data-action="copy-text">' + 1425 (I18N.t('share_copy_text') || 'Copia testo') + 1426 '</button>' + 1427 '</div>' + 1428 '</div>'; 1429 1430 overlay.querySelector('.share-modal-text').textContent = fullText; 1431 1432 document.body.appendChild(overlay); 1433 document.body.style.overflow = 'hidden'; 1434 1435 function close() { 1436 URL.revokeObjectURL(url); 1437 overlay.remove(); 1438 document.body.style.overflow = ''; 1439 } 1440 1441 overlay.addEventListener('click', function (e) { 1442 if (e.target === overlay) close(); 1443 }); 1444 overlay.querySelector('.share-modal-close').addEventListener('click', close); 1445 overlay.querySelector('[data-action="copy-text"]').addEventListener('click', function () { 1446 if (!navigator.clipboard) return; 1447 var self = this; 1448 navigator.clipboard.writeText(fullText).then(function () { 1449 self.textContent = I18N.t('share_copied') || 'Copiato!'; 1450 }); 1451 }); 1452 var copyImgEl = overlay.querySelector('[data-action="copy-image"]'); 1453 if (copyImgEl) { 1454 copyImgEl.addEventListener('click', function () { 1455 var self = this; 1456 var textBlob = new Blob([shortText], { type: 'text/plain' }); 1457 navigator.clipboard.write([ 1458 new ClipboardItem({ 1459 'image/png': blob, 1460 'text/plain': textBlob, 1461 }) 1462 ]).then(function () { 1463 self.textContent = I18N.t('share_copied') || 'Copiato!'; 1464 }).catch(function (err) { 1465 console.warn('Copy image not supported, falling back to text:', err); 1466 if (navigator.clipboard) { 1467 navigator.clipboard.writeText(fullText).then(function () { 1468 self.textContent = (I18N.t('share_copied') || 'Copiato!') + ' (testo)'; 1469 }).catch(function () { 1470 self.textContent = I18N.t('share_unsupported') || 'Non supportato'; 1471 }); 1472 } else { 1473 self.textContent = I18N.t('share_unsupported') || 'Non supportato'; 1474 } 1475 }); 1476 }); 1477 } 1478 } 1479 1480 function buildShareText() { 1481 var temp = document.getElementById('hero-temp-value')?.textContent || '--'; 1482 var humidity = document.getElementById('humidity-value')?.textContent || '--'; 1483 var pressure = document.getElementById('pressure-value')?.textContent || '--'; 1484 var aqi = document.getElementById('aqi-value')?.textContent || '--'; 1485 var aqiLabel = document.getElementById('aqi-label')?.textContent || ''; 1486 var now = new Date(); 1487 var time = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0'); 1488 var date = String(now.getDate()).padStart(2, '0') + '.' 1489 + String(now.getMonth() + 1).padStart(2, '0') + '.' 1490 + now.getFullYear(); 1491 1492 var lines = [ 1493 '\ud83d\udccd Pomarico - ' + date + ' - ' + time, 1494 '\ud83c\udf21\ufe0f ' + I18N.t('share_temp') + ' ' + temp, 1495 '\ud83d\udca7 ' + I18N.t('share_hum') + ' ' + humidity, 1496 '\ud83d\udd35 ' + I18N.t('share_press') + ' ' + pressure + ' hPa', 1497 ]; 1498 if (aqi !== '--') lines.push('\ud83c\udf3f ' + I18N.t('share_aqi') + ' ' + aqi + '
1498(' + aqiLabel + ')'); 1499 1500 var alertBar = document.getElementById('alert-bar'); 1501 var alertTitle = document.getElementById('alert-bar-title'); 1502 if (alertBar && alertTitle) { 1503 var isActive = !alertBar.classList.contains('alert-verde'); 1504 lines.push(isActive ? '\u26a0\ufe0f ' + alertTitle.textContent : '\u2705 ' + I18N.t('no_alert')); 1505 } 1506 1507 lines.push('\ud83d\udcca meteopomarico.it'); 1508 return lines.join('\n'); 1509 } 1510 1511 // === LANG TOGGLE === 1512 function initLangToggle() { 1513 var btn = document.getElementById('lang-toggle'); 1514 if (!btn) return; 1515 btn.textContent = I18N.currentLang === 'it' ? 'EN' : 'IT'; 1516 btn.addEventListener('click', function () { 1517 var next = I18N.currentLang === 'it' ? 'en' : 'it'; 1518 I18N.setLang(next); 1519 btn.textContent = next === 'it' ? 'EN' : 'IT'; 1520 // Ricarica dati per aggiornare testi dinamici 1521 loadWeatherData(); 1522 loadForecastData(); 1523 loadAlerts(); 1524 loadRecords(); 1525 loadStats(); 1526 loadMarineData(); 1527 updateMoonPhase(); 1528 updateChartTitle(currentChartRange); 1529 }); 1530 } 1531 1532 // === UNIT TOGGLE C / F === 1533 function initUnitToggle() { 1534 var saved = localStorage.getItem('tempUnit'); 1535 if (saved === 'F') useFahrenheit = true; 1536 1537 var btn = document.getElementById('unit-toggle'); 1538 if (!btn) return; 1539 1540 btn.textContent = useFahrenheit ? '\u00b0F' : '\u00b0C'; 1541 1542 btn.addEventListener('click', function () { 1543 useFahrenheit = !useFahrenheit; 1544 btn.textContent = useFahrenheit ? '\u00b0F' : '\u00b0C'; 1545 localStorage.setItem('tempUnit', useFahrenheit ? 'F' : 'C'); 1546 // Ricarica i dati per aggiornare tutte le temperature 1547 loadWeatherData(); 1548 loadForecastData(); 1549 }); 1550 } 1551 1552 // === GRAFICO STORICO ESTESO === 1553 function loadChartHistory(range) { 1554 fetch(API_URL + '?range=' + range) 1555 .then(function (r) { return r.json(); }) 1556 .then(function (data) { updateChart(data.history, range); }) 1557 .catch(function (err) { console.error('Errore storico chart:', err); }); 1558 } 1559 1560 function updateChartTitle(range) { 1561 var el = document.getElementById('chart-title'); 1562 if (!el) return; 1563 var keys = { '24h': 'chart_24h', '7d': 'chart_7d', '30d': 'chart_30d' }; 1564 el.textContent = I18N.t(keys[range] || 'chart_24h'); 1565 } 1566 1567 function formatChartLabel(timeStr, range) { 1568 var d = new Date(timeStr.replace(' ', 'T')); 1569 if (range === '24h') { 1570 return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); 1571 } 1572 if (range === '7d') { 1573 var dayKeys = ['day_sun', 'day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri', 'day_sat']; 1574 return I18N.t(dayKeys[d.getDay()]) + ' ' + String(d.getHours()).padStart(2, '0') + ':00'; 1575 } 1576 // 30d 1577 return String(d.getDate()).padStart(2, '0') + '/' + String(d.getMonth() + 1).padStart(2, '0'); 1578 } 1579 1580 // === ALLERTE METEO === 1581 function loadAlerts() { 1582 fetch(ALERTS_URL) 1583 .then(function (r) { 1584 if (!r.ok) throw new Error('HTTP ' + r.status); 1585 return r.json(); 1586 }) 1587 .then(function (data) { 1588 var bar = document.getElementById('alert-bar'); 1589 if (!bar) return; 1590 1591 // Mostra sempre la barra 1592 bar.style.display = 'block'; 1593 bar.className = 'alert-bar alert-' + data.level; 1594 1595 // Icona 1596 var icon = document.getElementById('alert-bar-icon'); 1597 if (icon) { 1598 if (data.active) { 1599 icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'; 1600 } else { 1601 icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="m9 12 2 2 4-4"/></svg>'; 1602 } 1603 } 1604 1605 // Titolo compatto 1606 var title = document.getElementById('alert-bar-title'); 1607 if (title) { 1608 if (data.active) { 1609 var colorKey = 'alert_' + data.level;
1610 var alertLabel = I18N.t(colorKey); 1611 // Se solo domani, aggiungi "(da domani)" nel titolo 1612 var hasOggi = data.alerts && data.alerts.some(function(a) { return a.period === 'oggi'; }); 1613 var hasDomani = data.alerts && data.alerts.some(function(a) { return a.period && a.period !== 'oggi'; }); 1614 var periodHint = ''; 1615 if (!hasOggi && hasDomani) { 1616 periodHint = ' (' + I18N.t('from_tomorrow') + ')'; 1617 } 1618 title.textContent = alertLabel + ' \u2014 ' + I18N.t('alert_source').split(' \u2014 ')[1] + periodHint; 1619 } else { 1620 title.textContent = I18N.t('no_alert'); 1621 } 1622 } 1623 1624 // Dettagli espandibili con pill colorate 1625 var details = document.getElementById('alert-bar-details'); 1626 if (details) { 1627 details.innerHTML = ''; 1628 if (data.active && data.alerts && data.alerts.length > 0) { 1629 var riskMap = { 1630 'Rischio Idraulico': 'risk_idraulico', 1631 'Temporali': 'risk_temporali', 1632 'Rischio Idrogeologico': 'risk_idrogeologico' 1633 }; 1634 var colorCssMap = { 1635 'giallo': 'giallo', 'gialla': 'giallo', 1636 'arancione': 'arancione', 1637 'rosso': 'rosso', 'rossa': 'rosso' 1638 }; 1639 1640 // Raggruppa per periodo 1641 var byPeriod = {}; 1642 data.alerts.forEach(function (a) { 1643 var p = a.period || 'oggi'; 1644 if (!byPeriod[p]) byPeriod[p] = []; 1645 byPeriod[p].push(a); 1646 }); 1647 1648 var periods = Object.keys(byPeriod); 1649 periods.forEach(function (period) { 1650 var section = document.createElement('div'); 1651 section.className = 'alert-bar-period'; 1652 1653 // Label periodo con data 1654 var label = document.createElement('div'); 1655 label.className = 'alert-bar-period-label'; 1656 var now = new Date(); 1657 if (period === 'oggi') { 1658 var dd = String(now.getDate()).padStart(2, '0'); 1659 var mm = String(now.getMonth() + 1).padStart(2, '0'); 1660 label.textContent = I18N.t('alert_period_today') + ' ' + dd + '/' + mm; 1661 } else { 1662 // "da domani DD/MM/YYYY" -> estrai la data 1663 var dateMatch = period.match(/(\d{2}\/\d{2})/); 1664 if (dateMatch) { 1665 label.textContent = I18N.t('alert_period_tomorrow') + ' ' + dateMatch[1]; 1666 } else { 1667 var tom = new Date(now.getTime() + 86400000); 1668 var tdd = String(tom.getDate()).padStart(2, '0'); 1669 var tmm = String(tom.getMonth() + 1).padStart(2, '0'); 1670 label.textContent = I18N.t('alert_period_tomorrow') + ' ' + tdd + '/' + tmm; 1671 } 1672 } 1673 section.appendChild(label); 1674 1675 // Pill per ogni rischio con colore tra parentesi 1676 byPeriod[period].forEach(function (a) { 1677 var pill = document.createElement('span'); 1678 var cssColor = colorCssMap[(a.color || '').toLowerCase()] || 'arancione'; 1679 pill.className = 'alert-bar-risk risk-' + cssColor; 1680 1681 var dot = document.createElement('span'); 1682 dot.className = 'alert-bar-risk-dot'; 1683 pill.appendChild(dot); 1684 1685 var translatedType = riskMap[a.type] ? I18N.t(riskMap[a.type]) : a.type; 1686 if (a.label) translatedType += ' (' + a.label + ')'; 1687 pill.appendChild(document.createTextNode(translatedType)); 1688 1689 section.appendChild(pill); 1690 }); 1691 1692 details.appendChild(section); 1693 }); 1694 1695 // Allerta attiva: parti sempre espanso (utente 1696 // clicca toggle per chiudere). Indipendente dal 1697 // numero di periodi. 1698 bar.classList.add('expanded'); 1699 1700 // Link bollettino PDF 1701 if (data.bulletin && data.bulletin.pdf) { 1702 var bulletinDiv = document.createElement('div'); 1703 bulletinDiv.className = 'alert-bar-bulletin'; 1704 var link = document.createElement('a'); 1705 link.className = 'alert-bar-bulletin-link'; 1706 link.href = data.bulletin.pdf; 1707 link.target = '_blank'; 1708 link.rel = 'noopener'; 1709 link.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> ' + I18N.t('alert_bulletin_link'); 1710 bulletinDiv.appendChild(link); 1711 details.appendChild(bulletinDiv); 1712 } 1713 } 1714 } 1715 1716 // Fonte 1717 var source = document.getElementById('alert-bar-source'); 1718 if (source && data.source) { 1719 source.textContent = I18N.t('source_label') + ': ' + data.source; 1720 } 1721 1722 // Toggle espandi/comprimi (solo se allerta attiva) 1723 var toggle = document.getElementById('alert-bar-toggle'); 1724 if (toggle && !toggle._hasListener) { 1725 toggle._hasListener = true; 1726 toggle.addEventListener('click', function () { 1727 if (!bar.classList.contains('alert-verde')) { 1728 bar.classList.toggle('expanded'); 1729 } 1730 }); 1731 } 1732 }) 1733 .catch(function (err) { 1734 console.error('Errore caricamento allerte:', err); 1735 }); 1736 } 1737 1738 // === THEME TOGGLE === 1739 function initThemeToggle() { 1740 var toggle = document.getElementById('theme-toggle'); 1741 if (!toggle) return; 1742 1743 toggle.addEventListener('click', function () { 1744 var current = document.documentElement.getAttribute('data-theme'); 1745 var next = current === 'light' ? 'dark' : 'light'; 1746 applyTheme(next); 1747 localStorage.setItem('theme', next); 1748 }); 1749 1750 window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function (e) { 1751 if (!localStorage.getItem('theme')) { 1752 applyTheme(e.matches ? 'light' : 'dark'); 1753 } 1754 }); 1755 } 1756 1757 function applyTheme(theme) { 1758 if (theme === 'light') { 1759 document.documentElement.setAttribute('data-theme', 'light'); 1760 } else { 1761 document.documentElement.removeAttribute('data-theme'); 1762 } 1763 var meta = document.querySelector('meta[name="theme-color"]'); 1764 if (meta) meta.setAttribute('content', theme === 'light' ? '#f0f4f8' : '#0a0e17'); 1765 updateChartTheme(); 1766 updateMarineMapTheme(theme); 1767 } 1768 1769 function getChartThemeColors() { 1770 var isLight = document.documentElement.getAttribute('data-theme') === 'light'; 1771 return { 1772 gridColor: isLight ? 'rgba(203, 213, 225, 0.5)' : 'rgba(42, 58, 80, 0.4)', 1773 tickColor: isLight ? '#475569' : '#64748b', 1774 tooltipBg: isLight ? '#ffffff' : '#1a2332', 1775 tooltipTitle: isLight ? '#0f172a' : '#f0f4f8', 1776 tooltipBody: isLight ? '#475569' : '#94a3b8', 1777 tooltipBorder:isLight ? '#cbd5e1' : '#2a3a50', 1778 }; 1779 } 1780 1781 function updateChartTheme() { 1782 if (!chart) return; 1783 var c = getChartThemeColors(); 1784 chart.options.scales.x.grid.color = c.gridColor; 1785 chart.options.scales.x.ticks.color = c.tickColor; 1786 chart.options.scales.y.grid.color = c.gridColor; 1787 chart.options.scales.y.ticks.color = c.tickColor; 1788 chart.options.scales.y.title.color = c.tickColor; 1789 chart.options.plugins.tooltip.backgroundColor = c.tooltipBg; 1790 chart.options.plugins.tooltip.titleColor = c.tooltipTitle; 1791 chart.options.plugins.tooltip.bodyColor = c.tooltipBody; 1792 chart.options.plugins.tooltip.borderColor = c.tooltipBorder; 1793 chart.update('none'); 1794 } 1795 1796 // === TEMPEST WEBSOCKET â VENTO REAL-TIME === 1797 var _wsRetryCount = 0; 1798 var _wsMaxRetries = 5; 1799 1800 function initTempestWebSocket() { 1801 var cfg = window.MP_TEMPEST || {}; 1802 var token = cfg.token || '6017ce2c-5c3f-41ef-9de8-083453b23afe'; 1803 var deviceId = cfg.deviceId || 120500;
1804 var wsUrl = 'wss://ws.weatherflow.com/swd/data?token=' + token; 1805 1806 try { 1807 var ws = new WebSocket(wsUrl); 1808 1809 ws.onopen = function () { 1810 _wsRetryCount = 0; 1811 // Sottoscrivi a rapid_wind (ogni 3 sec) e obs_st (ogni minuto) 1812 ws.send(JSON.stringify({ 1813 type: 'listen_rapid_start', 1814 device_id: deviceId, 1815 id: 'mp-rapid-' + Date.now() 1816 })); 1817 ws.send(JSON.stringify({ 1818 type: 'listen_start', 1819 device_id: deviceId, 1820 id: 'mp-obs-' + Date.now() 1821 })); 1822 console.log('Tempest WebSocket connesso'); 1823 // Indicatore live 1824 var dot = document.getElementById('live-dot'); 1825 if (dot) dot.classList.add('ws-live'); 1826 }; 1827 1828 ws.onmessage = function (event) { 1829 try { 1830 var msg = JSON.parse(event.data); 1831 1832 // rapid_wind: [timestamp, wind_mps, wind_direction] 1833 if (msg.type === 'rapid_wind' && msg.ob) { 1834 var windMps = msg.ob[1]; 1835 var windDir = msg.ob[2]; 1836 var windKmh = Math.round(windMps * 3.6 * 10) / 10; 1837 updateWindRealtime(windKmh, windDir); 1838 } 1839 1840 // obs_st: osservazione COMPLETA ogni ~1 minuto 1841 // Indici: 0=ts,1=lull,2=avg,3=gust,4=dir,5=interval, 1842 // 6=pressure,7=temp,8=humidity,9=lux,10=uv,11=solar, 1843 // 12=rain,13=precipType,14=strikeDist,15=strikeCount, 1844 // 16=battery,17=interval,18=dailyRain,19=rainNC,20=dailyRainNC 1845 if (msg.type === 'obs_st' && msg.obs && msg.obs[0]) { 1846 var o = msg.obs[0]; 1847 var toKmh = function(mps) { return mps != null ? Math.round(mps * 3.6 * 10) / 10 : null; }; 1848 1849 // Temperatura 1850 if (o[7] != null) { 1851 setTextIfExists('hero-temp-value', formatTemp(o[7])); 1852 // Aggiorna anche il titolo della tab (live via WebSocket) 1853 document.title = 'MeteoPomarico â ' + formatTemp(o[7]); 1854 } 1855 1856 // Umidita' 1857 if (o[8] != null) setTextIfExists('humidity-value', Math.round(o[8]) + '%'); 1858 1859 // Pressione (SLP approssimato: station + ~45 hPa per 380m altitudine) 1860 if (o[6] != null) { 1861 var slp = o[6] + 45.5; // approssimazione per 379.5m 1862 setTextIfExists('pressure-value', slp.toFixed(1)); 1863 } 1864 1865 // Vento raffiche + lull 1866 setTextIfExists('wind-stat-gusts', toKmh(o[3]) || '--'); 1867 var rangeEl = document.getElementById('wind-range'); 1868 if (rangeEl) rangeEl.textContent = (toKmh(o[1]) || 0) + ' - ' + (toKmh(o[3]) || 0); 1869 1870 // UV 1871 if (o[10] != null) { 1872 var uvEl = document.getElementById('uv-value'); 1873 if (uvEl) { 1874 uvEl.textContent = o[10].toFixed(1); 1875 var uvInfo = getUVInfo(o[10]); 1876 uvEl.closest('.card-value').className = 'card-value ' + uvInfo.cls; 1877 var uvLabel = document.getElementById('uv-label'); 1878 if (uvLabel) uvLabel.textContent = uvInfo.label; 1879 } 1880 } 1881 1882 // Radiazione solare + illuminamento 1883 if (o[11] != null) setTextIfExists('solar-rad-value', Math.round(o[11])); 1884 var illumEl = document.getElementById('illuminance-value'); 1885 if (illumEl && o[9] != null) { 1886 illumEl.textContent = I18N.t('illuminance_label') + ': ' + Math.round(o[9]).toLocaleStr
1886ing() + ' lux'; 1887 } 1888 1889 // Pioggia â aggiorna card completa 1890 var rainSection = document.getElementById('rain-section'); 1891 if (rainSection) { 1892 var wsRain = { 1893 current: o[12] || 0, 1894 last_1hr: null, // non disponibile in obs_st 1895 day: o[20] || o[18] || 0, 1896 yesterday: null, 1897 minutes_today: null 1898 }; 1899 // Mantieni last_1hr/yesterday dalla ultima chiamata API 1900 if (rainSection._lastStationRain) { 1901 wsRain.last_1hr = rainSection._lastStationRain.last_1hr; 1902 wsRain.yesterday = rainSection._lastStationRain.yesterday; 1903 wsRain.minutes_today = rainSection._lastStationRain.minutes_today; 1904 } 1905 rainSection.innerHTML = buildRainCard(wsRain); 1906 } 1907 1908 // Punto di rugiada (calcola da temp e umidita') 1909 if (o[7] != null && o[8] != null) { 1910 var dp = calcDewPoint(o[7], o[8]); 1911 setTextIfExists('dew-point-value', dp.toFixed(1)); 1912 var dpLabel = document.getElementById('dew-point-label'); 1913 if (dpLabel) dpLabel.textContent = getDewPointLabel(dp); 1914 } 1915 1916 // Fulmini 1917 if (o[15] != null) { 1918 var lCountEl = document.getElementById('lightning-count'); 1919 if (lCountEl && o[15] > 0) lCountEl.textContent = o[15]; 1920 } 1921 1922 // Timestamp aggiornamento 1923 var now = new Date(); 1924 setTextIfExists('data-timestamp', formatDateTime(now)); 1925 } 1926 } catch (e) { 1927 // Ignora errori di parsing 1928 } 1929 }; 1930 1931 ws.onclose = function () { 1932 console.log('Tempest WebSocket disconnesso'); 1933 var dot = document.getElementById('live-dot'); 1934 if (dot) dot.classList.remove('ws-live'); 1935 // Riconnetti con backoff 1936 if (_wsRetryCount < _wsMaxRetries) { 1937 _wsRetryCount++; 1938 var delay = Math.min(5000 * _wsRetryCount, 30000); 1939 setTimeout(initTempestWebSocket, delay); 1940 } 1941 }; 1942 1943 ws.onerror = function () { 1944 ws.close(); 1945 }; 1946 } catch (e) { 1947 console.error('WebSocket non supportato:', e); 1948 } 1949 } 1950 1951 function updateWindRealtime(speedKmh, direction) { 1952 var names = ['Tramontana', 'Grecale', 'Levante', 'Scirocco', 'Ostro', 'Libeccio', 'Ponente', 'Maestrale']; 1953 var idx = Math.round(direction / 45) % 8; 1954 var windName = names[idx]; 1955 1956 // Beaufort 1957 var bft = 0; 1958 var bftThresholds = [1, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118]; 1959 for (var i = 0; i < bftThresholds.length; i++) { 1960 if (speedKmh >= bftThresholds[i]) bft = i + 1; 1961 } 1962 1963 _prevWindDir = smoothRotation(_prevWindDir, direction); 1964 1965 // Aggiorna SEZIONE STAZIONE (principale) 1966 setTextIfExists('station-wind-speed', speedKmh); 1967 setTextIfExists('station-wind-degrees', direction + '\u00b0'); 1968 setTextIfExists('station-wind-name', I18N.t('wind_' + windName)); 1969 var arrow = document.getElementById('station-wind-arrow'); 1970 if (arrow) arrow.style.transform = 'rotate(' + _prevWindDir + 'deg)'; 1971 var bftEl = document.getElementById('station-wind-bft'); 1972 if (bftEl) { bftEl.textContent = bft; bftEl.className = 'wind-stat-value wind-stat-bft bft-' + bft; } 1973 var bftLbl = document.getElementById('station-wind-bft-label'); 1974 if (bftLbl) bftLbl.textContent = I18N.t('bft_' + bft); 1975 1976 // Aggiorna anche SEZIONE MARINE (se visibile) 1977 setTextIfExists('wind-stat-speed', speedKmh); 1978 setTextIfExists('wind-gauge-degrees', direction + '\u00b0'); 1979 setTextIfExists('wind-gauge-name', I18N.t('wind_' + windName)); 1980 var arrow2 = document.getElementById('wind-arrow-current'); 1981 if (arrow2) arrow2.style.transform = 'rotate(' + _prevWindDir + 'deg)'; 1982 var bftEl2 = document.getElementById('wind-stat-bft'); 1983 if (bftEl2) { bftEl2.textContent = bft; bftEl2.className = 'wind-stat-value wind-stat-bft bft-' + bft; } 1984 var bftLabel = document.getElementById('wind-bft-label'); 1985 if (bftLabel) bftLabel.textContent = I18N.t('bft_' + bft); 1986 } 1987 1988 // === VENTI E MARE DELLO IONIO LUCANO === 1989 function loadMarineData() { 1990 fetch(MARINE_URL) 1991 .then(function (r) { return r.json(); }) 1992 .then(function (data) { 1993 renderMarineSection(data); 1994 }) 1995 .catch(function (err) { console.error('Errore marine:', err); }); 1996 } 1997 1998 function renderMarineSection(data) { 1999 var section = document.getElementById('marine-section'); 2000 if (!section) return; 2001 section.style.display = ''; 2002 2003 // Consiglio automatico 2004 renderMarineAdvice(data.advice, data.wind, data.swell_note); 2005 2006 // Vento 2007 renderMarineWind(data.wind); 2008 2009 // Card riepilogo costa (singola costa: Ionio Lucano) 2010 renderCoastCards(data.locations); 2011 2012 // Mappa visuale 2013 renderMarineMap(data.locations); 2014 2015 // Previsione 3 giorni vento + mari 2016 renderMarineForecast(data.wind_forecast, data.locations); 2017 } 2018 2019 function renderMarineAdvice(advice, wind, swellNote) { 2020 var el = document.getElementById('marine-advice'); 2021 if (!el || !advice) return; 2022 el.style.display = ''; 2023 2024 // Classe per colore card 2025 el.className = 'marine-advice-card card advice-' + (advice.level || 'good'); 2026 2027 var iconEl = document.getElementById('marine-advice-icon'); 2028 var mainEl = document.getElementById('marine-advice-main'); 2029 var reasonEl = document.getElementById('marine-advice-reason'); 2030 2031 // Icona â triangolo per allerta, sole per buono, onda per mosso 2032 if (iconEl) {
2033 if (advice.key === 'advice_alert_active' || advice.key === 'advice_strong_wind') { 2034 iconEl.textContent = '\u26A0\uFE0F'; 2035 } else if (advice.key === 'advice_thunderstorm') { 2036 iconEl.textContent = '\u26C8\uFE0F'; 2037 } else if (advice.key === 'advice_rough_sea' || advice.key === 'advice_rough_everywhere' || advice.key === 'advice_ionian' || advice.key === 'advice_slight') { 2038 iconEl.textContent = '\uD83C\uDF0A'; 2039 } else if (advice.key === 'advice_calm_sea' || advice.key === 'advice_good_conditions' || advice.key === 'advice_calm' || advice.key === 'advice_ondina' || advice.key === 'advice_rain') { 2040 if (advice.weather_code !== null && advice.weather_code !== undefined) { 2041 iconEl.innerHTML = getWeatherIcon(advice.weather_code); 2042 } else { 2043 iconEl.textContent = '\u2600\uFE0F'; 2044 } 2045 } else { 2046 iconEl.textContent = '\uD83C\uDF0A'; 2047 } 2048 } 2049 2050 // Classe card: colore in base al tipo di consiglio 2051 if (advice.key === 'advice_alert_active') { 2052 el.className = 'marine-advice-card card advice-none'; 2053 } else if (advice.key === 'advice_strong_wind' || advice.key === 'advice_rough_sea' || advice.key === 'advice_rough_everywhere' || advice.key === 'advice_ionian' || advice.key === 'advice_thunderstorm') { 2054 el.className = 'marine-advice-card card advice-none'; 2055 } else { 2056 el.className = 'marine-advice-card card advice-entrambe'; 2057 } 2058 2059 // Titolo 2060 if (mainEl) mainEl.textContent = I18N.t(advice.key); 2061 2062 // Dettagli 2063 if (reasonEl) { 2064 var windNameStr = advice.wind_name ? I18N.t('wind_' + advice.wind_name) : ''; 2065 var lines = []; 2066 2067 // Riga 1: vento 2068 if (wind && windNameStr) { 2069 lines.push(windNameStr + ' ' + wind.speed + ' km/h'); 2070 } 2071 2072 // Riga 2: altezza onde Ionio 2073 var waveAvg = advice.avg_wave || advice.wave_avg; 2074 if (waveAvg !== undefined && waveAvg !== null) { 2075 lines.push(I18N.t('marine_taranto') + ': ' + waveAvg + ' m'); 2076 } 2077 2078 // Durante allerta: mostra messaggio Protezione Civile, no swell note 2079 if (advice.key === 'advice_alert_active') { 2080 lines.push(I18N.t(advice.reason_key)); 2081 } else if (swellNote) { 2082 // Pomarico è single-coast (Ionio): mostra sempre la nota swell 2083 // se presente. Il messaggio descrive l'evento passato che ha 2084 // generato le onde lunghe ancora in dissipazione. 2085 var lang = (typeof I18N !== 'undefined' && I18N.currentLang === 'en') ? 'en' : 'it'; 2086 var swellMsg = lang === 'en' ? swellNote.message_en : swellNote.message; 2087 if (swellMsg) { 2088 lines.push('<span class="swell-note">' + swellMsg + '</span>'); 2089 } 2090 } 2091 2092 reasonEl.innerHTML = lines.join('<br>'); 2093 } 2094 } 2095 2096 // Rotazione continua senza salti (350->10 va via +20, non -340) 2097 var _prevWindDir = null; 2098 2099 function smoothRotation(prev, target) { 2100 if (prev === null) return target; 2101 var diff = target - prev; 2102 if (diff > 180) diff -= 360; 2103 if (diff < -180) diff += 360; 2104 return prev + diff; 2105 } 2106 2107 // Aggiorna SOLO la bussola della stazione meteo Pomarico (entroterra, 2108 // stazione Tempest). Storicamente questa funzione sovrascriveva anche 2109 // la bussola marine + advice "VENTI E MARE DELLO IONIO" coi dati della 2110 // stazione, motivando "piu' precisi" â in realta' Pomarico e' ~30-40 km 2111 // dall'Ionio: a basse velocita' la direzione e' rumore. Il marine ora 2112 // prende il vento dall'aggregato vettoriale delle spiagge in api/marine.php. 2113 // Il nome della funzione e' rimasto per backward-compat (chiamato in 1 solo 2114 // punto, vedi loadWeatherData). 2115 function updateMarineWindFromStation(stationWind) { 2116 if (!stationWind || stationWind.speed == null) return; 2117 2118 var dir = stationWind.angle || 0; 2119 var speed = stationWind.speed; 2120 var gust = stationWind.gust; 2121 var lull = stationWind.lull; 2122 2123 var names = ['Tramontana', 'Grecale', 'Levante', 'Scirocco', 'Ostro', 'Libeccio', 'Ponente', 'Maestrale']; 2124 var idx = Math.round(dir / 45) % 8; 2125 var windName = names[idx]; 2126 2127 // === SEZIONE STAZIONE (bussola principale) === 2128 setTextIfExists('station-wind-degrees', dir + '\u00b0'); 2129 setTextIfExists('station-wind-name', I18N.t('wind_' + windName)); 2130 setTextIfExists('station-wind-speed', speed); 2131 if (gust != null) setTextIfExists('station-wind-gusts', gust); 2132 2133 var rangeEl = document.getElementById('station-wind-range'); 2134 if (rangeEl && gust != null) { 2135 rangeEl.textContent = (lull || 0) + ' - ' + gust; 2136 } 2137 2138 // Beaufort 2139 var bft = 0; 2140 var bftThresholds = [1, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118]; 2141 for (var i = 0; i < bftThresholds.length; i++) { 2142 if (speed >= bftThresholds[i]) bft = i + 1; 2143 } 2144 var bftEl = document.getElementById('station-wind-bft'); 2145 if (bftEl) { 2146 bftEl.textContent = bft; 2147 bftEl.className = 'wind-stat-value wind-stat-bft bft-' + bft; 2148 } 2149 var bftLabel = document.getElementById('station-wind-bft-label'); 2150 if (bftLabel) bftLabel.textContent = I18N.t('bft_' + bft); 2151 2152 // Freccia bussola stazione 2153 _prevWindDir = smoothRotation(_prevWindDir, dir); 2154 var arrow = document.getElementById('station-wind-arrow'); 2155 if (arrow) { 2156 arrow.style.transform = 'rotate(' + _prevWindDir + 'deg)'; 2157 } 2158 2159 // [RIMOSSO] Override bussola marine con dati stazione inland. 2160 // La sezione marine prende il vento dall'aggregato vettoriale 2161 // delle spiagge (api/marine.php), fonte corretta per dati marittimi. 2162 2163 // [RIMOSSO] Override testo advice marine con dati stazione inland. 2164 // L'advice viene popolato da renderMarineAdvice() col vento aggregato 2165 // delle spiagge â non lo tocchiamo qui. 2166 } 2167 2168 function renderMarineWind(wind) { 2169 if (!wind) return; 2170 2171 // Testo centrale 2172 setTextIfExists('wind-gauge-degrees', wind.direction + '\u00b0'); 2173 setTextIfExists('wind-gauge-name', I18N.t('wind_' + wind.name)); 2174 2175 // Stats 2176 setTextIfExists('wind-stat-speed', wind.speed); 2177 setTextIfExists('wind-stat-gusts', wind.gusts); 2178 2179 // Range lull-gust (come Tempest: "1 - 9") 2180 var rangeEl = document.getElementById('wind-range'); 2181 if (rangeEl && wind.gusts != null) { 2182 var lull = wind.lull != null ? wind.lull : 0; 2183 rangeEl.textContent = lull + ' - ' + wind.gusts; 2184 } 2185 2186 // Beaufort 2187 if (wind.beaufort) { 2188 var bftEl = document.getElementById('wind-stat-bft'); 2189 var bftLabelEl = document.getElementById('wind-bft-label'); 2190 if (bftEl) { 2191 bftEl.textContent = wind.beaufort.level; 2192 bftEl.className = 'wind-stat-value wind-stat-bft bft-' + wind.beaufort.level; 2193 } 2194 if (bftLabelEl) { 2195 bftLabelEl.textContent = I18N.t('bft_' + wind.beaufort.level) || wind.beaufort.label; 2196 } 2197 } 2198 2199 // Ruota freccia (CSS transform, 3s transition) 2200 _prevWindDir = smoothRotation(_prevWindDir, wind.direction); 2201 var arrow = document.getElementById('wind-arrow-current'); 2202 if (arrow) { 2203 arrow.style.transform = 'rotate(' + _prevWindDir + 'deg)'; 2204 } 2205 } 2206 2207 var MARINE_WAVES_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>'; 2208 2209 function seaWaveColor(loc) { 2210 if (loc && loc.sea_state && SEA_STATE_COLORS[loc.sea_state.color]) { 2211 return SEA_STATE_COLORS[loc.sea_state.color]; 2212 } 2213 return '#64748b'; 2214 } 2215 2216 // Card singola spiaggia (stile app): onda grande colorata + stato + score
2216+ acqua 2217 function renderBeachCard(loc) { 2218 var color = seaWaveColor(loc); 2219 var wave = (loc.wave_height !== null && loc.wave_height !== undefined) ? loc.wave_height.toFixed(2) : '--'; 2220 var state = loc.sea_state ? I18N.t(loc.sea_state.key) : '--'; 2221 var score = loc.score ? '<div class="bc-score">' + loc.score + '/10</div>' : ''; 2222 var temp = (loc.sea_temp !== null && loc.sea_temp !== undefined) ? '<div class="bc-temp">' + formatTemp(loc.sea_temp) + '</div>' : ''; 2223 return '<div class="beach-card">' + 2224 '<div class="bc-name">' + loc.name + '</div>' + 2225 '<div class="bc-wave" style="color:' + color + '">' + wave + '<small>m</small></div>' + 2226 '<div class="bc-state">' + state + '</div>' + 2227 score + temp + 2228 '</div>'; 2229 } 2230 2231 function renderCoastBlock(titleKey, icon, locs) { 2232 if (!locs || locs.length === 0) return ''; 2233 var out = '<div class="section-title marine-coast-title">' + icon + 2234 '<span data-i18n="' + titleKey + '">' + I18N.t(titleKey) + '</span></div>'; 2235 out += '<div class="beach-scroll">'; 2236 locs.forEach(function (loc) { out += renderBeachCard(loc); }); 2237 out += '</div>'; 2238 return out; 2239 } 2240 2241 // Card per singola spiaggia a scorrimento (come su LecceMeteo). Qui la costa 2242 // e' una sola (Ionica Lucana), quindi un unico blocco con tutte le localita 2243 // nell'ordine di api/marine.php (da nord-est a sud-ovest). 2244 function renderBeachScroll(locations) { 2245 var container = document.getElementById('marine-coasts'); 2246 if (!container || !locations) return; 2247 container.innerHTML = renderCoastBlock('coast_ionian_lucana', MARINE_WAVES_ICON, locations); 2248 } 2249 2250 // Card per singola spiaggia a scorrimento (blocco "Costa Ionica Lucana"). 2251 // Prima qui c'era anche la card aggregata "Mar Ionio" con la lista delle 2252 // localita': mostrava le stesse dieci spiagge delle card qui sotto, quindi 2253 // e' stata rimossa dal DOM insieme al codice che la riempiva. 2254 function renderCoastCards(locations) { 2255 if (!locations || locations.length === 0) return; 2256 renderBeachScroll(locations); 2257 } 2258 2259 function getScoreLabel(score) { 2260 if (score >= 7) return I18N.t('score_ottimo'); 2261 if (score >= 5) return I18N.t('score_buono'); 2262 if (score >= 3) return I18N.t('score_discreto'); 2263 return I18N.t('score_sconsigliato'); 2264 } 2265 2266 var SEA_STATE_COLORS = { 2267 'sea-calm': '#22c55e', 2268 'sea-ripple': '#06b6d4', 2269 'sea-slight': '#eab308', 2270 'sea-moderate': '#f97316', 2271 'sea-rough': '#ef4444', 2272 'sea-very-rough': '#a855f7', 2273 }; 2274 2275 var marineMap = null; 2276 var marineMarkers = []; 2277 var lastMarineBounds = null; 2278 2279 // Chiamato dallo switch dei tab all'apertura di "Mappa": la mappa MapLibre 2280 // viene creata anche mentre il tab e' nascosto (container 0x0), quindi al 2281 // primo show serve un resize + refit sui bounds calcolati. 2282 window.meteopomaricoShowMarineTab = function () { 2283 if (!marineMap) return; 2284 marineMap.resize(); 2285 if (lastMarineBounds && !lastMarineBounds.isEmpty()) { 2286 marineMap.fitBounds(lastMarineBounds, { padding: { top: 40, bottom: 40, left: 20, right: 180 }, maxZoom: 10.5 }); 2287 } 2288 }; 2289 2290 function renderMarineMap(locations) { 2291 if (!locations || typeof maplibregl === 'undefined') return; 2292 2293 var mapEl = document.getElementById('marine-leaflet-map'); 2294 if (!mapEl) return; 2295 2296 // Stile base: CARTO vector tiles (gratuiti, no API key) 2297 var isLight = document.documentElement.getAttribute('data-theme') === 'light'; 2298 var styleUrl = isLight 2299 ? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' 2300 : 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; 2301 2302 // Inizializza mappa solo la prima volta 2303 if (!marineMap) { 2304 marineMap = new maplibregl.Map({ 2305 container: 'marine-leaflet-map', 2306 style: styleUrl, 2307 center: [16.78, 40.28], 2308 zoom: 9, 2309 attributionControl: false, 2310 scrollZoom: false, 2311 cooperativeGestures: true, 2312 }); 2313 2314 marineMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); 2315 marineMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right'); 2316 2317 marineMap.on('load', function () { 2318 2319 // Marker spiagge sopra i layer 2320 addMarineMarkers(locations); 2321 }); 2322 } else {
2323 marineMarkers.forEach(function (m) { m.remove(); }); 2324 marineMarkers = []; 2325 addMarineMarkers(locations); 2326 } 2327 } 2328 2329 function addMarineMarkers(locations) { 2330 marineMarkers.forEach(function (m) { m.remove(); }); 2331 marineMarkers = []; 2332 2333 var bounds = new maplibregl.LngLatBounds(); 2334 2335 locations.forEach(function (loc) { 2336 if (loc.wave_height === null) return; 2337 2338 var color = loc.sea_state ? (SEA_STATE_COLORS[loc.sea_state.color] || '#64748b') : '#64748b'; 2339 var stateText = loc.sea_state ? I18N.t(loc.sea_state.key) : '--'; 2340 var waveText = loc.wave_height + ' m'; 2341 var tempText = loc.sea_temp !== null ? loc.sea_temp + '\u00b0' : ''; 2342 var scoreText = loc.score ? loc.score + '/10' : ''; 2343 2344 // Marker con label integrato (dot + nome) 2345 var el = document.createElement('div'); 2346 el.className = 'marine-marker-label'; 2347 el.innerHTML = 2348 '<span class="mml-dot" style="background:' + color + ';"></span>' + 2349 '<span class="mml-text">' + 2350 '<strong>' + loc.name + '</strong>' + 2351 '<span class="mml-info">' + stateText + ' \u2022 ' + waveText + 2352 (tempText ? ' \u2022 <span class="mml-temp">' + tempText + '</span>' : '') + 2353 (scoreText ? ' \u2022 <span class="mml-score">' + scoreText + '</span>' : '') + 2354 '</span>' + 2355 '</span>'; 2356 2357 // Popup dettagliato al click 2358 var popupHtml = '<div style="font-family:Inter,sans-serif;font-size:13px;line-height:1.6;padding:4px;">' + 2359 '<strong style="font-size:14px;">' + loc.name + '</strong><br>' + 2360 '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + color + ';margin-right:5px;vertical-align:middle;"></span>' + 2361 '<strong>' + stateText + '</strong> \u2022 ' + waveText + '<br>' + 2362 (loc.sea_temp !== null ? '\uD83C\uDF21 ' + I18N.t('marine_sea_temp') + ': <strong style="color:#06b6d4;">' + loc.sea_temp + '\u00b0C</strong><br>' : '') + 2363 (loc.score ? '\u2B50 Score: <strong style="color:#7B9CFF;">' + loc.score + '/10</strong> â ' + getScoreLabel(loc.score) : '') + 2364 '</div>'; 2365 2366 var popup = new maplibregl.Popup({ 2367 offset: [0, -8], 2368 closeButton: false, 2369 maxWidth: '250px' 2370 }).setHTML(popupHtml); 2371 2372 var marker = new maplibregl.Marker({ element: el, anchor: 'left' }) 2373 .setLngLat([loc.lon, loc.lat]) 2374 .setPopup(popup) 2375 .addTo(marineMap); 2376 2377 marineMarkers.push(marker); 2378 bounds.extend([loc.lon, loc.lat]); 2379 }); 2380 2381 lastMarineBounds = bounds; 2382 if (!bounds.isEmpty()) { 2383 marineMap.fitBounds(bounds, { padding: { top: 40, bottom: 40, left: 20, right: 180 }, maxZoom: 10.5 }); 2384 } 2385 } 2386 2387 function renderMarineForecast(forecast, locations) { 2388 var container = document.getElementById('marine-forecast-container'); 2389 if (!container || !forecast || forecast.length === 0) return; 2390 2391 // Pre-calcola medie onde per ogni timestep forecast 2392 var coastWaves = {}; 2393 if (locations) { 2394 locations.forEach(function (loc) { 2395 if (!loc.forecast) return; 2396 loc.forecast.forEach(function (ff) { 2397 if (!coastWaves[ff.time]) coastWaves[ff.time] = []; 2398 coastWaves[ff.time].push(ff.wave_height); 2399 }); 2400 }); 2401 } 2402 2403 var html = ''; 2404 var prevDay = ''; 2405 forecast.forEach(function (f) { 2406 var d = new Date(f.time); 2407 var dayNames = [ 2408 I18N.t('day_sun'), I18N.t('day_mon'), I18N.t('day_tue'), 2409 I18N.t('day_wed'), I18N.t('day_thu'), I18N.t('day_fri'), I18N.t('day_sat') 2410 ]; 2411 var dayName = dayNames[d.getDay()]; 2412 var hourStr = String(d.getHours()).padStart(2, '0') + ':00'; 2413 var dayKey = d.toDateString(); 2414 var showDayLabel = dayKey !== prevDay; 2415 prevDay = dayKey; 2416 2417 // Stato mare medio a questo timestep 2418 var waveArr = coastWaves[f.time]; 2419 var waveStr = ''; 2420 if (waveArr && waveArr.length > 0) { 2421 var avg = waveArr.reduce(function(a, b) { return a + b; }, 0) / waveArr.length; 2422 waveStr = avg.toFixed(1) + 'm'; 2423 } 2424 2425 html += '<div class="marine-forecast-item' + (showDayLabel ? ' new-day' : '') + '">'; 2426 if (showDayLabel) { 2427 html += '<div class="marine-forecast-day">' + dayName + '</div>'; 2428 } 2429 html += '<div class="marine-forecast-time">' + hourStr + '</div>' + 2430 '<div class="marine-forecast-arrow" style="transform:rotate(' + f.direction + 'deg)">\u2193</div>' + 2431 '<div class="marine-forecast-wind-name">' + I18N.t('wind_' + f.name) + '</div>' + 2432 '<div class="marine-forecast-wind-speed">' + f.speed + ' km/h</div>'; 2433 2434 if (waveStr) { 2435 html += '<div class="marine-forecast-seas">'; 2436 html += '<span class="fc-sea" title="Mar Ionio">' + waveStr + '</span>'; 2437 html += '</div>'; 2438 } 2439 2440 html += '</div>'; 2441 }); 2442 2443 container.innerHTML = html; 2444 } 2445 2446 // === ROSA DEI VENTI (polarArea) === 2447 function renderWindRose(history) { 2448 if (!history || history.length === 0) return; 2449 var canvas = document.getElementById('wind-rose-chart'); 2450 if (!canvas || !canvas.getContext) return; 2451 2452 var ctx = canvas.getContext('2d'); 2453 var W = canvas.width; 2454 var H = canvas.height; 2455 var cx = W / 2; 2456 var cy = H / 2; 2457 var maxR = Math.min(cx, cy) - 30; 2458 2459 var sectorNames = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; 2460 var windNames = ['Tramontana', 'Grecale', 'Levante', 'Scirocco', 'Ostro', 'Libeccio', 'Ponente', 'Maestrale']; 2461 // Angoli: N=270deg canvas (su), NE=315, E=0, SE=45, S=90, SW=135, W=180, NW=225 2462 var angles = [-90, -45, 0, 45, 90, 135, 180, -135]; // gradi canvas 2463 2464 var totalPoints = 0; 2465 var counts = [0, 0, 0, 0, 0, 0, 0, 0]; 2466 var speedSums = [0, 0, 0, 0, 0, 0, 0, 0]; 2467 var maxSpeeds = [0, 0, 0, 0, 0, 0, 0, 0]; 2468
2469 history.forEach(function (point) { 2470 if (point.wind_angle == null) return; 2471 var idx = Math.round(point.wind_angle / 45) % 8; 2472 counts[idx]++; 2473 totalPoints++; 2474 var spd = point.wind_speed || 0; 2475 speedSums[idx] += spd; 2476 if (spd > maxSpeeds[idx]) maxSpeeds[idx] = spd; 2477 }); 2478 2479 if (totalPoints === 0) return; 2480 2481 var percentages = counts.map(function (c) { return c / totalPoints; }); 2482 var avgSpeeds = counts.map(function (c, i) { return c > 0 ? speedSums[i] / c : 0; }); 2483 var maxPct = Math.max.apply(null, percentages); 2484 if (maxPct === 0) maxPct = 1; 2485 2486 var maxIdx = 0; 2487 for (var i = 1; i < counts.length; i++) { 2488 if (counts[i] > counts[maxIdx]) maxIdx = i; 2489 } 2490 2491 var isLight = document.documentElement.getAttribute('data-theme') === 'light'; 2492 2493 // Colore petalo basato su velocita' media 2494 function petalColor(spd, alpha) { 2495 if (spd < 5) return 'rgba(96, 165, 250, ' + alpha + ')'; 2496 if (spd < 15) return 'rgba(59, 130, 246, ' + alpha + ')'; 2497 if (spd < 30) return 'rgba(37, 99, 235, ' + alpha + ')'; 2498 return 'rgba(124, 58, 237, ' + alpha + ')'; 2499 } 2500 2501 // Pulisci canvas 2502 ctx.clearRect(0, 0, W, H); 2503 2504 // Cerchi concentrici di riferimento 2505 var gridColor = isLight ? 'rgba(203,213,225,0.5)' : 'rgba(42,58,80,0.4)'; 2506 var textColor = isLight ? '#475569' : '#64748b'; 2507 for (var g = 1; g <= 4; g++) { 2508 var gr = maxR * g / 4; 2509 ctx.beginPath(); 2510 ctx.arc(cx, cy, gr, 0, Math.PI * 2); 2511 ctx.strokeStyle = gridColor; 2512 ctx.lineWidth = 1; 2513 ctx.stroke(); 2514 } 2515 2516 // Linee cardinali sottili 2517 for (var l = 0; l < 8; l++) { 2518 var la = angles[l] * Math.PI / 180; 2519 ctx.beginPath(); 2520 ctx.moveTo(cx, cy); 2521 ctx.lineTo(cx + Math.cos(la) * maxR, cy + Math.sin(la) * maxR); 2522 ctx.strokeStyle = gridColor; 2523 ctx.lineWidth = 0.5; 2524 ctx.stroke(); 2525 } 2526 2527 // Petali 2528 for (var p = 0; p < 8; p++) { 2529 var pct = percentages[p]; 2530 var r = maxR * (pct / maxPct) * 0.9 + maxR * 0.1; // Minimo 10% 2531 if (counts[p] === 0) r = maxR * 0.05; 2532 var a = angles[p] * Math.PI / 180; 2533 var halfAngle = (22.5) * Math.PI / 180; // Mezza ampiezza settore 2534 2535 // Disegna petalo come arco 2536 ctx.beginPath(); 2537 ctx.moveTo(cx, cy); 2538 ctx.arc(cx, cy, r, a - halfAngle, a + halfAngle); 2539 ctx.closePath(); 2540 2541 // Gradiente radiale per il petalo 2542 var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); 2543 grd.addColorStop(0, petalColor(avgSpeeds[p], 0.2)); 2544 grd.addColorStop(0.5, petalColor(avgSpeeds[p], 0.5)); 2545 grd.addColorStop(1, petalColor(avgSpeeds[p], 0.8)); 2546 ctx.fillStyle = grd; 2547 ctx.fill(); 2548 2549 // Bordo petalo 2550 ctx.strokeStyle = petalColor(avgSpeeds[p], 1); 2551 ctx.lineWidth = p === maxIdx ? 2.5 : 1; 2552 ctx.stroke(); 2553 } 2554 2555 // Cerchio centrale 2556 ctx.beginPath(); 2557 ctx.arc(cx, cy, 6, 0, Math.PI * 2); 2558 ctx.fillStyle = isLight ? '#475569' : '#94a3b8'; 2559 ctx.fill(); 2560 2561 // Freccia direzione attuale (dal weather data piu' recente) 2562 var lastPoint = history[history.length - 1]; 2563 if (lastPoint && lastPoint.wind_angle != null) { 2564 var currentAngle = (lastPoint.wind_angle - 90) * Math.PI / 180; // Converti a canvas 2565 var arrowLen = maxR * 0.35; 2566 var ax = cx + Math.cos(currentAngle) * arrowLen; 2567 var ay = cy + Math.sin(currentAngle) * arrowLen; 2568 2569 ctx.beginPath(); 2570 ctx.moveTo(cx, cy); 2571 ctx.lineTo(ax, ay); 2572 ctx.strokeStyle = '#ef4444'; 2573 ctx.lineWidth = 2.5; 2574 ctx.stroke(); 2575 2576 // Punta freccia 2577 var headLen = 8; 2578 var headAngle = 0.5; 2579 ctx.beginPath(); 2580 ctx.moveTo(ax, ay); 2581 ctx.lineTo(ax - headLen * Math.cos(currentAngle - headAngle), ay - headLen * Math.sin(currentAngle - headAngle)); 2582 ctx.lineTo(ax - headLen * Math.cos(currentAngle + headAngle), ay - headLen * Math.sin(currentAngle + headAngle)); 2583 ctx.closePath(); 2584 ctx.fillStyle = '#ef4444'; 2585 ctx.fill(); 2586 } 2587 2588 // Labels direzione 2589 ctx.textAlign = 'center'; 2590 ctx.textBaseline = 'middle'; 2591 for (var t = 0; t < 8; t++) { 2592 var ta = angles[t] * Math.PI / 180; 2593 var labelR = maxR + 18;
2594 var lx = cx + Math.cos(ta) * labelR; 2595 var ly = cy + Math.sin(ta) * labelR; 2596 ctx.font = (t === maxIdx ? 'bold 14px' : '12px') + ' Inter, sans-serif'; 2597 ctx.fillStyle = t === maxIdx ? '#3b82f6' : textColor; 2598 ctx.fillText(sectorNames[t], lx, ly); 2599 } 2600 2601 // Legenda laterale 2602 var legendEl = document.getElementById('wind-rose-legend'); 2603 if (legendEl) { 2604 var pctDisplay = Math.round(percentages[maxIdx] * 100); 2605 var lhtml = '<div style="font-size:1.1rem;font-weight:800;color:var(--text-primary);">' 2606 + I18N.t('wind_' + windNames[maxIdx]) + '</div>'; 2607 lhtml += '<div style="font-size:1.8rem;font-weight:800;color:var(--accent);">' + pctDisplay + '%</div>'; 2608 lhtml += '<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:1rem;">' 2609 + I18N.t('wind_rose_dominant') + '</div>'; 2610 2611 // Stats per settore dominante 2612 lhtml += '<div style="font-size:0.8rem;margin-bottom:0.75rem;">'; 2613 lhtml += '<div>' + I18N.t('wind_rose_avg') + ': <strong>' + avgSpeeds[maxIdx].toFixed(1) + ' km/h</strong></div>'; 2614 lhtml += '<div>' + I18N.t('wind_rose_max') + ': <strong>' + maxSpeeds[maxIdx].toFixed(1) + ' km/h</strong></div>'; 2615 lhtml += '</div>'; 2616 2617 // Scala colori 2618 lhtml += '<div style="font-size:0.7rem;color:var(--text-muted);margin-bottom:4px;">' + I18N.t('marine_speed') + '</div>'; 2619 var speedRanges = [ 2620 { label: '< 5', color: 'rgba(96,165,250,0.7)' }, 2621 { label: '5-15', color: 'rgba(59,130,246,0.8)' }, 2622 { label: '15-30', color: 'rgba(37,99,235,0.9)' }, 2623 { label: '> 30', color: 'rgba(124,58,237,0.9)' }, 2624 ];
2625 speedRanges.forEach(function (r) { 2626 lhtml += '<div style="display:flex;align-items:center;gap:5px;margin:2px 0;">' + 2627 '<span style="width:10px;height:10px;border-radius:2px;background:' + r.color + ';"></span>' + 2628 '<span style="font-size:0.7rem;">' + r.label + ' km/h</span></div>'; 2629 }); 2630 2631 // Indicatore freccia rossa 2632 lhtml += '<div style="display:flex;align-items:center;gap:5px;margin-top:8px;">' + 2633 '<span style="width:16px;height:2px;background:#ef4444;"></span>' + 2634 '<span style="font-size:0.7rem;color:var(--text-muted);">' + I18N.t('wind_rose_current') + '</span></div>'; 2635 2636 legendEl.innerHTML = lhtml; 2637 } 2638 } 2639 2640 // === GRAFICO PIOGGIA A BARRE === 2641 var rainChart = null; 2642 2643 function renderRainChart(history) { 2644 if (!history || history.length === 0) return; 2645 var canvas = document.getElementById('rain-chart'); 2646 if (!canvas) return; 2647 2648 var labels = []; 2649 var rateData = []; 2650 var cumulData = []; 2651 var barColors = []; 2652
2653 history.forEach(function (point) { 2654 var d = new Date(point.time.replace(' ', 'T')); 2655 labels.push(String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')); 2656 2657 var rate = point.rain_rate != null ? point.rain_rate : 0; 2658 rateData.push(rate); 2659 2660 var cumul = point.rain_day != null ? point.rain_day : 0; 2661 cumulData.push(cumul); 2662 2663 // Color by intensity 2664 if (rate < 1) barColors.push('rgba(34, 197, 94, 0.7)'); 2665 else if (rate < 4) barColors.push('rgba(234, 179, 8, 0.7)'); 2666 else if (rate < 16) barColors.push('rgba(249, 115, 22, 0.7)'); 2667 else barColors.push('rgba(239, 68, 68, 0.7)'); 2668 }); 2669 2670 var tc = getChartThemeColors(); 2671 2672 var datasets = [ 2673 { 2674 label: 'mm/h', 2675 type: 'bar', 2676 data: rateData, 2677 backgroundColor: barColors, 2678 borderColor: barColors.map(function (c) { return c.replace('0.7', '1'); }), 2679 borderWidth: 1, 2680 yAxisID: 'y', 2681 order: 2 2682 }, 2683 { 2684 label: 'mm (cum.)', 2685 type: 'line', 2686 data: cumulData, 2687 borderColor: '#3b82f6', 2688 backgroundColor: 'rgba(59, 130, 246, 0.1)', 2689 borderWidth: 2, 2690 fill: true, 2691 tension: 0.4, 2692 pointRadius: 0, 2693 pointHitRadius: 10, 2694 yAxisID: 'y1', 2695 order: 1 2696 } 2697 ]; 2698 2699 if (rainChart) { 2700 rainChart.data.labels = labels; 2701 rainChart.data.datasets = datasets; 2702 rainChart.update('none'); 2703 } else { 2704 rainChart = new Chart(canvas.getContext('2d'), { 2705 type: 'bar', 2706 data: { labels: labels, datasets: datasets }, 2707 options: { 2708 responsive: true, 2709 maintainAspectRatio: false, 2710 interaction: { mode: 'index', intersect: false }, 2711 plugins: { 2712 legend: { display: true, labels: { color: tc.tickColor, font: { size: 11 } } }, 2713 tooltip: { 2714 backgroundColor: tc.tooltipBg, 2715 titleColor: tc.tooltipTitle, 2716 bodyColor: tc.tooltipBody, 2717 borderColor: tc.tooltipBorder, 2718 borderWidth: 1, 2719 padding: 12, 2720 cornerRadius: 10 2721 } 2722 }, 2723 scales: { 2724 x: { 2725 grid: { color: tc.gridColor, drawBorder: false }, 2726 ticks: { color: tc.tickColor, maxTicksLimit: 12, font: { size: 11 } } 2727 }, 2728 y: { 2729 type: 'linear', 2730 position: 'left', 2731 grid: { color: tc.gridColor, drawBorder: false }, 2732 ticks: { color: tc.tickColor, font: { size: 11 } }, 2733 title: { display: true, text: 'mm/h', color: tc.tickColor }, 2734 beginAtZero: true 2735 }, 2736 y1: { 2737 type: 'linear', 2738 position: 'right', 2739 grid: { drawOnChartArea: false }, 2740 ticks: { color: '#3b82f6', font: { size: 11 } }, 2741 title: { display: true, text: 'mm', color: '#3b82f6' }, 2742 beginAtZero: true 2743 } 2744 } 2745 } 2746 }); 2747 } 2748 } 2749 2750 function updateMarineMapTheme(theme) { 2751 if (!marineMap) return; 2752 var styleUrl = theme === 'light' 2753 ? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' 2754 : 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; 2755 marineMap.setStyle(styleUrl); 2756 marineMap.once('styledata', function() { 2757 // Stile cambiato 2758 }); 2759 } 2760 2761})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.