signalk-mareas-ihm 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "2.1.3",
3
- "timestamp": "20260609-0150",
2
+ "version": "2.1.5",
3
+ "timestamp": "20260609-0225",
4
4
  "gitHash": null,
5
5
  "gitDirty": true,
6
- "builtAt": "2026-06-08T23:50:21.804Z"
6
+ "builtAt": "2026-06-09T00:25:39.157Z"
7
7
  }
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ function isPositionValue(v) {
49
49
  // timestamp + git hash so we can verify exactly which build is running on the Pi
50
50
  // without ambiguity. ("¿Qué versión tengo deployada?" → /api/paths or landing.)
51
51
  const PLUGIN_VERSION = esmRequire("../package.json").version;
52
- const PLUGIN_REVISION = "Rev314";
52
+ const PLUGIN_REVISION = "Rev317";
53
53
  let _buildInfo = null;
54
54
  try {
55
55
  _buildInfo = esmRequire("./build-info.json");
@@ -3507,6 +3507,24 @@ export default function (app) {
3507
3507
  }
3508
3508
  });
3509
3509
  expressApp.get("/signalk-mareas-ihm/navtiles/:z/:x/:y.png", async (req, res) => {
3510
+ /* Rev317: guard res.headersSent en todos los error paths. Si la conexión
3511
+ upstream falla MID-STREAM (después de pipe()), no se puede llamar a
3512
+ res.status/send porque ya se enviaron headers → "Cannot set headers
3513
+ after they are sent". Solución: solo enviar 502 si headersSent=false;
3514
+ en otro caso destruir el socket para cortar el stream colgado. */
3515
+ const sendFallback = () => {
3516
+ if (res.headersSent) {
3517
+ try {
3518
+ res.end();
3519
+ }
3520
+ catch { /* socket already closed */ }
3521
+ return;
3522
+ }
3523
+ try {
3524
+ res.status(502).set("Content-Type", "image/png").send(TRANSPARENT_PNG);
3525
+ }
3526
+ catch { /* ignore */ }
3527
+ };
3510
3528
  try {
3511
3529
  const { token, bearer } = await fetchChartTokens();
3512
3530
  const z = req.params.z, x = req.params.x, y = req.params.y;
@@ -3519,16 +3537,24 @@ export default function (app) {
3519
3537
  "Origin": "https://maps.garmin.com"
3520
3538
  }
3521
3539
  }, (tileResp) => {
3540
+ if (res.headersSent) {
3541
+ try {
3542
+ tileResp.destroy();
3543
+ }
3544
+ catch { }
3545
+ return;
3546
+ }
3522
3547
  const ct = tileResp.headers["content-type"] || "image/png";
3523
3548
  res.set("Content-Type", ct);
3524
3549
  res.set("Cache-Control", "public, max-age=15552000, immutable");
3550
+ tileResp.on("error", sendFallback);
3525
3551
  tileResp.pipe(res);
3526
3552
  });
3527
- tileReq.on("error", () => { res.status(502).send(TRANSPARENT_PNG); });
3553
+ tileReq.on("error", sendFallback);
3528
3554
  tileReq.end();
3529
3555
  }
3530
3556
  catch {
3531
- res.status(502).send(TRANSPARENT_PNG);
3557
+ sendFallback();
3532
3558
  }
3533
3559
  });
3534
3560
  // ═══════════════ SSE (Server-Sent Events) for multi-device sync ═══════════════
package/dist/mobile.html CHANGED
@@ -755,6 +755,33 @@ body.mobile-ui #m-bottom-bar .m-bb-grade .ic{font-size:10px}
755
755
  body.mobile-ui #m-bottom-bar .m-bb-grade #m-bb-grade{width:28px!important;height:28px!important;font-size:18px!important}
756
756
  body.mobile-ui #m-bottom-bar #m-bb-pres-svg{width:50px!important;height:18px!important}
757
757
 
758
+ /* Rev315: estilos UNIFICADOS de back-button y título en TODAS las ventanas.
759
+ Estas clases utility se pueden aplicar a cualquier botón/título de un modal
760
+ para forzar la misma apariencia visual en todo el plugin. Color principal
761
+ var(--org) naranja, peso 900, tamaño 22px back y 28px título. */
762
+ .m-back-btn{
763
+ background:rgba(255,178,63,.15)!important;
764
+ border:1px solid var(--org)!important;
765
+ color:var(--org)!important;
766
+ font-size:18px!important;
767
+ font-weight:800!important;
768
+ padding:12px 18px!important;
769
+ border-radius:12px!important;
770
+ cursor:pointer!important;
771
+ min-height:48px!important;
772
+ display:inline-flex!important;align-items:center!important;gap:4px!important;
773
+ letter-spacing:-.2px!important;
774
+ }
775
+ .m-back-btn:active{opacity:.6!important;transform:scale(.95)!important}
776
+ .m-modal-title{
777
+ font-size:28px!important;
778
+ font-weight:900!important;
779
+ color:#fff!important;
780
+ letter-spacing:-.3px!important;
781
+ text-align:center!important;
782
+ margin:0!important;
783
+ }
784
+
758
785
  /* === MODALES fullscreen Rev173: contenido MUCHO más grande, sin pinch, sin scroll horizontal === */
759
786
  body.mobile-ui .popup-overlay{
760
787
  align-items:stretch!important;justify-content:stretch!important;
@@ -790,19 +817,24 @@ body.mobile-ui .m-modal-hdr{
790
817
  display:flex;align-items:center;justify-content:space-between;
791
818
  padding:0 12px;
792
819
  }
820
+ /* Rev315: m-back UNIFICADO (alineado con clase utility .m-back-btn). Ya no
821
+ necesita min-width:110px ni height fija — es más compacto y consistente
822
+ con todos los demás botones Back del proyecto (Anchor Calculation, etc). */
793
823
  body.mobile-ui .m-modal-hdr .m-back{
794
824
  background:rgba(255,178,63,.15);border:1px solid var(--org);color:var(--org);
795
- font-size:22px;font-weight:800;cursor:pointer;padding:14px 20px;
796
- display:flex;align-items:center;gap:6px;min-width:110px;height:56px;
797
- border-radius:12px;
825
+ font-size:18px;font-weight:800;cursor:pointer;padding:12px 18px;
826
+ display:flex;align-items:center;gap:4px;min-height:48px;
827
+ border-radius:12px;letter-spacing:-.2px;
798
828
  }
799
829
  body.mobile-ui .m-modal-hdr .m-back:active{opacity:.6;transform:scale(.95)}
830
+ /* Rev315: título UNIFICADO 28px (antes 38px). Coherente con la clase
831
+ utility .m-modal-title y con todos los modales del proyecto. */
800
832
  body.mobile-ui .m-modal-hdr .m-title{
801
- flex:1;text-align:center;font-size:38px;font-weight:900;color:#fff;
833
+ flex:1;text-align:center;font-size:28px;font-weight:900;color:#fff;
802
834
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
803
- padding:0 8px;letter-spacing:-.4px;
835
+ padding:0 8px;letter-spacing:-.3px;
804
836
  }
805
- body.mobile-ui .m-modal-hdr .m-close-spacer{min-width:110px}
837
+ body.mobile-ui .m-modal-hdr .m-close-spacer{min-width:80px}
806
838
 
807
839
  /* === Tipografía GIGANTE dentro de modales (legibilidad marítima de sol) ===
808
840
  Rev183: subimos todo +2-4 px más. El usuario insiste en que todavía
@@ -1271,26 +1303,24 @@ body.mobile-ui #shelter-hours .sh-hr{flex:0 0 60px!important}
1271
1303
  <!-- Rev296: botones izquierdos dentro de #m-leftbar (scroll vertical, mismo
1272
1304
  estilo que #m-sidebar derecha). -->
1273
1305
  <div id="m-leftbar">
1274
- <button id="m-ham" title="Menú" onclick="document.getElementById('m-menu').classList.add('open')"><span class="ico">☰</span></button>
1275
- <button id="m-cartas" title="Activar / desactivar audio" onclick="toggleAudioEnable&&toggleAudioEnable()">
1306
+ <button id="m-ham" data-i18n-title="lb_ham_tip" title="Menú" onclick="document.getElementById('m-menu').classList.add('open')"><span class="ico">☰</span></button>
1307
+ <button id="m-cartas" data-i18n-title="lb_audio_tip" title="Activar / desactivar audio" onclick="toggleAudioEnable&&toggleAudioEnable()">
1276
1308
  <span class="ico" id="m-mute-icon">🔊</span>
1277
1309
  <span class="lbl" data-i18n="m_silencio">Silencio</span>
1278
1310
  </button>
1279
- <button id="m-snooze" title="Snooze 5 min" onclick="m_toggleSnooze()">
1311
+ <button id="m-snooze" data-i18n-title="lb_snooze_tip" title="Snooze 5 min" onclick="m_toggleSnooze()">
1280
1312
  <span class="ico" id="m-snooze-icon">💤</span>
1281
1313
  <span class="lbl" id="m-snooze-lbl" data-i18n="m_snooze">Snooze</span>
1282
1314
  </button>
1283
- <button id="m-fav" title="Fondeo favorito" onclick="m_favClick()">
1315
+ <button id="m-fav" data-i18n-title="lb_fav_tip" title="Fondeo favorito" onclick="m_favClick()">
1284
1316
  <span class="ico">❤</span>
1285
1317
  <span class="lbl" data-i18n="m_favorito">Favorito</span>
1286
1318
  </button>
1287
- <!-- Rev296: acceso directo a Curvas y Mareas también desde la left bar
1288
- (entre Favorito y KIP). Mismos handlers que los del sidebar derecho. -->
1289
- <button id="m-lb-curvas" title="Curvas" onclick="openPopup('curvas-pop');fetchCurvas&&fetchCurvas()">
1319
+ <button id="m-lb-curvas" data-i18n-title="sb_curvas" title="Curvas" onclick="openPopup('curvas-pop');fetchCurvas&&fetchCurvas()">
1290
1320
  <span class="ico">〰️</span>
1291
1321
  <span class="lbl" data-i18n="sb_curvas">Curvas</span>
1292
1322
  </button>
1293
- <button id="m-lb-mareas" title="Mareas (TidesView)" onclick="m_openMareas&&m_openMareas()">
1323
+ <button id="m-lb-mareas" data-i18n-title="lb_mareas_tip" title="Mareas (TidesView)" onclick="m_openMareas&&m_openMareas()">
1294
1324
  <span class="ico" style="display:inline-flex;align-items:center;justify-content:center">
1295
1325
  <svg viewBox="0 0 44 44" width="30" height="30">
1296
1326
  <rect x="2" y="2" width="40" height="14" rx="2" fill="#ffd966"/>
@@ -1302,10 +1332,10 @@ body.mobile-ui #shelter-hours .sh-hr{flex:0 0 60px!important}
1302
1332
  </span>
1303
1333
  <span class="lbl" data-i18n="sb_mareas">Mareas</span>
1304
1334
  </button>
1305
- <button id="m-kip" title="Abrir KIP dashboard" onclick="window.location.href = window.location.origin + '/@mxtommy/kip/'">
1335
+ <button id="m-kip" data-i18n-title="lb_kip_tip" title="Abrir KIP dashboard" onclick="window.location.href = window.location.origin + '/@mxtommy/kip/'">
1306
1336
  <span class="ico" style="font-weight:900;letter-spacing:1px">KIP</span>
1307
1337
  </button>
1308
- <button id="m-fb" title="Abrir Freeboard-SK" onclick="window.location.href = window.location.origin + '/@signalk/freeboard-sk/'">
1338
+ <button id="m-fb" data-i18n-title="lb_fb_tip" title="Abrir Freeboard-SK" onclick="window.location.href = window.location.origin + '/@signalk/freeboard-sk/'">
1309
1339
  <img src="assets/freeboard.png" alt="Freeboard"
1310
1340
  style="width:42px;height:42px;object-fit:contain;background:#fff;border-radius:6px;padding:2px;display:block;box-sizing:border-box"/>
1311
1341
  </button>
@@ -1357,7 +1387,7 @@ body.mobile-ui #shelter-hours .sh-hr{flex:0 0 60px!important}
1357
1387
  <span class="vl" id="m-bb-wind-kt-txt">—<span style="font-size:11px;font-weight:700;opacity:.75;margin-left:2px">kts</span></span>
1358
1388
  </div>
1359
1389
  </div>
1360
- <div class="it" data-i18n-title="bb_sonda_tip" title="Profundidad bajo quilla · Sonda" onclick="openPopup&&openPopup('sonda-pop')"><span class="lb" data-i18n="bb_sonda">Sonda</span><span class="vl" id="m-bb-sonda">—</span><span id="m-bb-sonda-status" style="display:none;font-size:9px;font-weight:800;margin-top:2px;text-align:center;line-height:1.1"></span></div>
1390
+ <div class="it" data-i18n-title="bb_sonda_tip" title="Profundidad bajo quilla · Sonda" onclick="openPopup&&openPopup('sonda-pop');typeof fetchSondaData==='function'&&fetchSondaData()"><span class="lb" data-i18n="bb_sonda">Sonda</span><span class="vl" id="m-bb-sonda">—</span><span id="m-bb-sonda-status" style="display:none;font-size:9px;font-weight:800;margin-top:2px;text-align:center;line-height:1.1"></span></div>
1361
1391
  <div class="it" data-i18n-title="bb_cadena_tip" title="Cadena recomendada" onclick="m_openInfoModal&&m_openInfoModal()"><span class="lb" data-i18n="bb_cadena_rec">Cadena rec.</span><span class="vl" id="m-bb-cad">—</span></div>
1362
1392
  <div class="it" data-i18n-title="bb_dist_tip" title="Distancia al ancla" onclick="m_openInfoModal&&m_openInfoModal()"><span class="lb" data-i18n="bb_dist_ancla">Dist. ancla</span><span class="vl" id="m-bb-dist">—</span></div>
1363
1393
  <div class="it m-bb-graph" data-i18n-title="bb_presion_tip" title="Presión barométrica · línea naranja = AHORA · click → Meteo" onclick="toggleMeteo&&toggleMeteo()">
@@ -1561,18 +1591,20 @@ body.mobile-ui #shelter-hours .sh-hr{flex:0 0 60px!important}
1561
1591
  </div>
1562
1592
  <!-- Popup info histórico ola: leyenda colores + tabla detallada. -->
1563
1593
  <div class="popup-overlay" id="wave-hist-info-pop" onclick="closePopup('wave-hist-info-pop')">
1564
- <div class="popup-box" onclick="event.stopPropagation()" style="width:620px;max-width:96vw;max-height:88vh;overflow-y:auto;padding:18px 22px">
1565
- <button onclick="closePopup('wave-hist-info-pop')" style="position:absolute;top:8px;right:12px;background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1">✕</button>
1566
- <h4 style="margin:0 0 8px 0;font-size:19px">Historial de olas — leyenda y detalle</h4>
1567
- <div style="font-size:14px;color:#9ad;margin-bottom:14px">Cada barra resume 15 minutos. Color = intensidad media medida por el IMU del barco. Altura = oscilación del bin.</div>
1568
- <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:18px">
1569
- <div style="text-align:center"><div style="width:100%;height:18px;background:#3aa856;border-radius:3px"></div><div style="font-size:12px;color:#fff;margin-top:2px;font-weight:700" data-i18n="m_calma">Calma</div><div style="font-size:10px;color:#888">&lt;0.5°</div></div>
1570
- <div style="text-align:center"><div style="width:100%;height:18px;background:#a3c813;border-radius:3px"></div><div style="font-size:12px;color:#fff;margin-top:2px;font-weight:700">Rizada</div><div style="font-size:10px;color:#888">0.5–1.5°</div></div>
1571
- <div style="text-align:center"><div style="width:100%;height:18px;background:#f3c812;border-radius:3px"></div><div style="font-size:12px;color:#fff;margin-top:2px;font-weight:700">Moderada</div><div style="font-size:10px;color:#888">1.5–3°</div></div>
1572
- <div style="text-align:center"><div style="width:100%;height:18px;background:#f37812;border-radius:3px"></div><div style="font-size:12px;color:#fff;margin-top:2px;font-weight:700">Agitada</div><div style="font-size:10px;color:#888">36°</div></div>
1573
- <div style="text-align:center"><div style="width:100%;height:18px;background:#e74c3c;border-radius:3px"></div><div style="font-size:12px;color:#fff;margin-top:2px;font-weight:700">Fuerte</div><div style="font-size:10px;color:#888">&gt;6°</div></div>
1594
+ <!-- Rev316: ventana wave-hist-info ampliada (antes 620px / 88vh) y todo
1595
+ bilingüe. Estilo título alineado con resto de modales (.m-modal-title). -->
1596
+ <div class="popup-box" onclick="event.stopPropagation()" style="width:900px;max-width:96vw;max-height:92vh;overflow-y:auto;padding:28px 32px">
1597
+ <button onclick="closePopup('wave-hist-info-pop')" style="position:absolute;top:10px;right:14px;background:none;border:none;color:#aaa;font-size:28px;cursor:pointer;line-height:1">✕</button>
1598
+ <h4 style="margin:0 0 12px 0;font-size:24px;font-weight:900;color:#fff" data-i18n="sh_wh_title">Historial de olas — leyenda y detalle</h4>
1599
+ <div style="font-size:16px;color:#9ad;margin-bottom:18px" data-i18n="sh_wh_subtitle">Cada barra resume 15 minutos. Color = intensidad media medida por el IMU del barco. Altura = oscilación del bin.</div>
1600
+ <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:22px">
1601
+ <div style="text-align:center"><div style="width:100%;height:22px;background:#3aa856;border-radius:3px"></div><div style="font-size:14px;color:#fff;margin-top:4px;font-weight:700" data-i18n="m_calma">Calma</div><div style="font-size:12px;color:#888">&lt;0.5°</div></div>
1602
+ <div style="text-align:center"><div style="width:100%;height:22px;background:#a3c813;border-radius:3px"></div><div style="font-size:14px;color:#fff;margin-top:4px;font-weight:700" data-i18n="sh_wh_rizada">Rizada</div><div style="font-size:12px;color:#888">0.51.5°</div></div>
1603
+ <div style="text-align:center"><div style="width:100%;height:22px;background:#f3c812;border-radius:3px"></div><div style="font-size:14px;color:#fff;margin-top:4px;font-weight:700" data-i18n="sh_wh_moderada">Moderada</div><div style="font-size:12px;color:#888">1.5–3°</div></div>
1604
+ <div style="text-align:center"><div style="width:100%;height:22px;background:#f37812;border-radius:3px"></div><div style="font-size:14px;color:#fff;margin-top:4px;font-weight:700" data-i18n="sh_wh_agitada">Agitada</div><div style="font-size:12px;color:#888">3–6°</div></div>
1605
+ <div style="text-align:center"><div style="width:100%;height:22px;background:#e74c3c;border-radius:3px"></div><div style="font-size:14px;color:#fff;margin-top:4px;font-weight:700" data-i18n="sh_wh_fuerte">Fuerte</div><div style="font-size:12px;color:#888">&gt;6°</div></div>
1574
1606
  </div>
1575
- <div id="wave-hist-info-table" style="font-size:13px;color:#cfd8dc"></div>
1607
+ <div id="wave-hist-info-table" style="font-size:15px;color:#cfd8dc"></div>
1576
1608
  </div>
1577
1609
  </div>
1578
1610
  <!-- Rev79: popup explicacion del % de proteccion -->
@@ -1744,6 +1776,12 @@ body.mobile-ui #shelter-hours .sh-hr{flex:0 0 60px!important}
1744
1776
  <div class="alarm-info"><b style="color:var(--red)" data-i18n="alarma_sonda">🌊 Alarma Sonda</b><br><small style="color:#aaa" data-i18n="alarma_sonda_desc">Riesgo de varada por profundidad</small><div class="alarm-st" id="alarm-sonda-st"></div></div>
1745
1777
  <div class="alarm-sw"><input type="checkbox" id="chk-alarm-sonda" onclick="event.stopPropagation()" onchange="setAlarm('sonda',this.checked)"><label for="chk-alarm-sonda" onclick="event.stopPropagation()"></label></div>
1746
1778
  </div>
1779
+ <!-- Rev316: nuevo selector "Mala condición climática" — alerta cuando
1780
+ la previsión meteo en las próximas 6h tiene viento > 25 kt o ola > 1.5 m. -->
1781
+ <div class="alarm-row" id="ar-weather" onclick="var c=document.getElementById('chk-alarm-weather');c.checked=!c.checked;setAlarm('weather',c.checked)">
1782
+ <div class="alarm-info"><b style="color:#ffb23f" data-i18n="alarma_meteo">⛈️ Mala condición climática</b><br><small style="color:#aaa" data-i18n="alarma_meteo_desc">Aviso si previsión 6h supera viento 25 kt u ola 1.5 m</small><div class="alarm-st" id="alarm-weather-st"></div></div>
1783
+ <div class="alarm-sw"><input type="checkbox" id="chk-alarm-weather" onclick="event.stopPropagation()" onchange="setAlarm('weather',this.checked)"><label for="chk-alarm-weather" onclick="event.stopPropagation()"></label></div>
1784
+ </div>
1747
1785
  </div>
1748
1786
  <div id="alarm-active-list" style="margin-top:6px"></div>
1749
1787
  <!-- Rev184: orden correcto — debajo de alarmas va el VOLUMEN, luego
@@ -2023,6 +2061,54 @@ var _i18n={
2023
2061
  sb_abrigo_tip:{es:'Previsión abrigo',en:'Shelter forecast'},
2024
2062
  sb_alarmas_tip:{es:'Panel de alarmas',en:'Alarms panel'},
2025
2063
  sb_fondeo_tip:{es:'Cálculo fondeo',en:'Anchorage calculator'},
2064
+ /* Rev315: meteo panel AHORA + row labels */
2065
+ met_viento:{es:'VIENTO',en:'WIND'},
2066
+ met_aire:{es:'AIRE',en:'AIR'},
2067
+ met_mar:{es:'MAR',en:'SEA'},
2068
+ met_resumen:{es:'Resumen',en:'Summary'},
2069
+ met_row_aire:{es:'Aire',en:'Air'},
2070
+ met_row_presion:{es:'Presión',en:'Pressure'},
2071
+ met_row_lluvia:{es:'Lluvia',en:'Rain'},
2072
+ met_row_tmar:{es:'T. Mar',en:'Sea T.'},
2073
+ met_row_ola:{es:'Ola',en:'Wave'},
2074
+ met_row_periodo:{es:'Período',en:'Period'},
2075
+ met_row_dirola:{es:'Dir ola',en:'Wave dir'},
2076
+ /* Rev315: shelter NOW box */
2077
+ sh_veleta:{es:'Veleta',en:'Vane'},
2078
+ sh_sensor:{es:'Sensor',en:'Sensor'},
2079
+ sh_racha:{es:'racha',en:'gust'},
2080
+ sh_aire:{es:'aire',en:'air'},
2081
+ sh_agua:{es:'agua',en:'water'},
2082
+ m_ahora_low:{es:'ahora',en:'now'},
2083
+ /* Rev315: labels flotantes visor (mapa) */
2084
+ m_viento:{es:'Viento',en:'Wind'},
2085
+ m_olas:{es:'Olas',en:'Waves'},
2086
+ m_mar_calma:{es:'Mar en calma',en:'Calm sea'},
2087
+ /* Rev316: wave-hist-info popup */
2088
+ sh_wh_title:{es:'Historial de olas — leyenda y detalle',en:'Wave history — legend & details'},
2089
+ sh_wh_subtitle:{es:'Cada barra resume 15 minutos. Color = intensidad media medida por el IMU del barco. Altura = oscilación del bin.',en:'Each bar summarises 15 minutes. Color = average intensity measured by boat IMU. Bar height = bin oscillation.'},
2090
+ sh_wh_no_data:{es:'No hay datos todavía. Echa el ancla y espera al menos 15 min para que aparezcan barras.',en:'No data yet. Drop anchor and wait at least 15 min for bars to appear.'},
2091
+ sh_wh_inicio:{es:'Inicio',en:'Start'},
2092
+ sh_wh_intensidad:{es:'Intensidad',en:'Intensity'},
2093
+ sh_wh_periodo:{es:'Período',en:'Period'},
2094
+ sh_wh_altura:{es:'Altura',en:'Height'},
2095
+ sh_wh_rms:{es:'RMS',en:'RMS'},
2096
+ sh_wh_rizada:{es:'Rizada',en:'Rippled'},
2097
+ sh_wh_moderada:{es:'Moderada',en:'Moderate'},
2098
+ sh_wh_agitada:{es:'Agitada',en:'Rough'},
2099
+ sh_wh_fuerte:{es:'Fuerte',en:'Strong'},
2100
+ sh_wh_actual:{es:'ACTUAL',en:'CURRENT'},
2101
+ /* Rev316: tooltips left bar */
2102
+ lb_ham_tip:{es:'Menú',en:'Menu'},
2103
+ lb_audio_tip:{es:'Activar / desactivar audio',en:'Enable / disable audio'},
2104
+ lb_snooze_tip:{es:'Snooze 5 min',en:'Snooze 5 min'},
2105
+ lb_fav_tip:{es:'Fondeo favorito',en:'Favourite anchorage'},
2106
+ lb_mareas_tip:{es:'Mareas (TidesView)',en:'Tides (TidesView)'},
2107
+ lb_kip_tip:{es:'Abrir KIP dashboard',en:'Open KIP dashboard'},
2108
+ lb_fb_tip:{es:'Abrir Freeboard-SK',en:'Open Freeboard-SK'},
2109
+ /* Rev316: alarma meteo */
2110
+ alarma_meteo:{es:'⛈️ Mala condición climática',en:'⛈️ Bad weather conditions'},
2111
+ alarma_meteo_desc:{es:'Aviso si previsión 6h supera viento 25 kt u ola 1.5 m',en:'Warning if 6h forecast exceeds wind 25 kt or wave 1.5 m'},
2026
2112
  /* Rev313: modal guardar favorito */
2027
2113
  fav_guardar_title:{es:'❤ Guardar fondeo favorito',en:'❤ Save favourite anchorage'},
2028
2114
  fav_guardar_hint:{es:'Nombre del favorito (vacío = nombre automático por geolocalización):',en:'Favourite name (empty = auto name by geolocation):'},
@@ -4797,25 +4883,32 @@ function _renderWaveHistInfo(){
4797
4883
  var bins=Array.isArray(j.bins)?j.bins.slice():[];
4798
4884
  if(j.currentBin)bins.push(j.currentBin);
4799
4885
  if(bins.length===0){
4800
- el.innerHTML='<div style="color:#888;text-align:center;padding:12px">No hay datos todavía. Echa el ancla y espera al menos 15 min para que aparezcan barras.</div>';
4886
+ var _noDataMsg=(typeof T==='function')?T('sh_wh_no_data','No hay datos todavía. Echa el ancla y espera al menos 15 min para que aparezcan barras.'):'No hay datos todavía. Echa el ancla y espera al menos 15 min para que aparezcan barras.';
4887
+ el.innerHTML='<div style="color:#888;text-align:center;padding:12px">'+_noDataMsg+'</div>';
4801
4888
  return;
4802
4889
  }
4803
- /* Encabezado tabla */
4804
- var rows=['<div style="display:grid;grid-template-columns:80px 1fr 60px 70px 90px;gap:4px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.18);font-weight:800;color:#9ad;font-size:12px;letter-spacing:.8px;text-transform:uppercase">',
4805
- '<span>Inicio</span>','<span>Intensidad</span>','<span style="text-align:right">Período</span>','<span style="text-align:right">Altura</span>','<span style="text-align:right">RMS</span>',
4890
+ /* Rev316: headers i18n. */
4891
+ var _tw=function(k,fb){ return (typeof T==='function')?T(k,fb):fb; };
4892
+ var _hInicio=_tw('sh_wh_inicio','Inicio'), _hInten=_tw('sh_wh_intensidad','Intensidad'),
4893
+ _hPer=_tw('sh_wh_periodo','Período'), _hAlt=_tw('sh_wh_altura','Altura'), _hRms=_tw('sh_wh_rms','RMS');
4894
+ var _capCalma=_tw('m_calma','Calma'), _capRiz=_tw('sh_wh_rizada','Rizada'),
4895
+ _capMod=_tw('sh_wh_moderada','Moderada'), _capAgi=_tw('sh_wh_agitada','Agitada'), _capFue=_tw('sh_wh_fuerte','Fuerte');
4896
+ var _capMap={calma:_capCalma,rizada:_capRiz,moderada:_capMod,agitada:_capAgi,fuerte:_capFue};
4897
+ var _badgeActual=_tw('sh_wh_actual','ACTUAL');
4898
+ var rows=['<div style="display:grid;grid-template-columns:80px 1fr 60px 70px 90px;gap:4px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.18);font-weight:800;color:#9ad;font-size:13px;letter-spacing:.8px;text-transform:uppercase">',
4899
+ '<span>'+_hInicio+'</span>','<span>'+_hInten+'</span>','<span style="text-align:right">'+_hPer+'</span>','<span style="text-align:right">'+_hAlt+'</span>','<span style="text-align:right">'+_hRms+'</span>',
4806
4900
  '</div>'];
4807
- /* Más recientes arriba */
4808
4901
  var sorted=bins.slice().reverse();
4809
4902
  for(var i=0;i<sorted.length;i++){
4810
4903
  var b=sorted[i];
4811
4904
  var d=new Date(b.binStartMs);
4812
4905
  var hhmm=('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2);
4813
4906
  var color=_WAVE_INT_COLORS[b.intensityMode]||'#888';
4814
- var intCap=(b.intensityMode||'').charAt(0).toUpperCase()+(b.intensityMode||'').slice(1);
4907
+ var intCap=_capMap[b.intensityMode||'']||((b.intensityMode||'').charAt(0).toUpperCase()+(b.intensityMode||'').slice(1));
4815
4908
  var per=(typeof b.periodAvgSec==='number'&&b.periodAvgSec>0)?b.periodAvgSec.toFixed(1)+'s':'—';
4816
4909
  var alt=(typeof b.heightSigMaxM==='number'&&b.heightSigMaxM>0)?b.heightSigMaxM.toFixed(2).replace('.',',')+' m':'<5cm';
4817
4910
  var rms=(typeof b.rmsMaxDeg==='number'&&b.rmsMaxDeg>0)?b.rmsMaxDeg.toFixed(2)+'°':'—';
4818
- var curBadge=b.isCurrent?' <span style="font-size:10px;color:#ffeb3b;font-weight:800;margin-left:4px">ACTUAL</span>':'';
4911
+ var curBadge=b.isCurrent?' <span style="font-size:10px;color:#ffeb3b;font-weight:800;margin-left:4px">'+_badgeActual+'</span>':'';
4819
4912
  rows.push('<div style="display:grid;grid-template-columns:80px 1fr 60px 70px 90px;gap:4px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.06);align-items:center">'+
4820
4913
  '<span style="color:#fff;font-weight:700">'+hhmm+'</span>'+
4821
4914
  '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:'+color+';margin-right:6px;vertical-align:middle"></span>'+intCap+curBadge+'</span>'+
@@ -5324,8 +5417,11 @@ function _shelterRenderPopup(){
5324
5417
  temperaturas. Sin sensor → forecast Open-Meteo (sin badge).
5325
5418
  _BADGE_VELETA y _BADGE_SENSOR son verdes (#3aa856), mismo tono que
5326
5419
  el badge "IMU" pre-existente para coherencia visual. */
5327
- var _BADGE_VELETA=' <span style="color:#3aa856;font-size:11px;font-weight:800;margin-left:4px">Veleta</span>';
5328
- var _BADGE_SENSOR=' <span style="color:#3aa856;font-size:10px;font-weight:800;margin-left:3px">Sensor</span>';
5420
+ /* Rev315: badges Veleta/Sensor i18n. */
5421
+ var _badgeVeletaTxt=(typeof T==='function')?T('sh_veleta','Veleta'):'Veleta';
5422
+ var _badgeSensorTxt=(typeof T==='function')?T('sh_sensor','Sensor'):'Sensor';
5423
+ var _BADGE_VELETA=' <span style="color:#3aa856;font-size:11px;font-weight:800;margin-left:4px">'+_badgeVeletaTxt+'</span>';
5424
+ var _BADGE_SENSOR=' <span style="color:#3aa856;font-size:10px;font-weight:800;margin-left:3px">'+_badgeSensorTxt+'</span>';
5329
5425
  var _useSensorWind=isNow&&_boatWind;
5330
5426
  var _useSensorEnv=isNow&&_envSensors;
5331
5427
  var stWindKt,stDirDeg,windBadge;
@@ -5481,7 +5577,7 @@ function _shelterRenderPopup(){
5481
5577
  var axis=
5482
5578
  '<text x="'+x_24h.toFixed(1)+'" y="'+(H-6)+'" text-anchor="start" fill="rgba(255,255,255,.78)" font-size="13" font-weight="700">-24 h</text>'+
5483
5579
  '<text x="'+x_12h.toFixed(1)+'" y="'+(H-6)+'" text-anchor="middle" fill="rgba(255,255,255,.65)" font-size="13" font-weight="700">-12 h</text>'+
5484
- '<text x="'+nowX.toFixed(1)+'" y="'+(H-6)+'" text-anchor="middle" fill="#ffeb3b" font-size="13" font-weight="900">ahora</text>'+
5580
+ '<text x="'+nowX.toFixed(1)+'" y="'+(H-6)+'" text-anchor="middle" fill="#ffeb3b" font-size="13" font-weight="900">'+((typeof T==='function')?T('m_ahora_low','ahora'):'ahora')+'</text>'+
5485
5581
  '<text x="'+x_max.toFixed(1)+'" y="'+(H-6)+'" text-anchor="end" fill="rgba(255,235,59,.85)" font-size="13" font-weight="800">+'+maxFutureH+' h</text>'+
5486
5582
  '<text x="2" y="'+(padTop+10)+'" text-anchor="start" fill="rgba(255,255,255,.6)" font-size="11" font-weight="700">'+Math.round(pMax)+'</text>'+
5487
5583
  '<text x="2" y="'+(H-padBot-2)+'" text-anchor="start" fill="rgba(255,255,255,.6)" font-size="11" font-weight="700">'+Math.round(pMin)+'</text>';
@@ -5525,11 +5621,13 @@ function _shelterRenderPopup(){
5525
5621
  /* Rev128: reorden infobox — wind, gust, wave, TEMPS, pressure-num, sparkline.
5526
5622
  La sparkline queda al final para visual cleanliness (es el elemento más
5527
5623
  alto y rompía si quedaba en medio). */
5624
+ /* Rev315: labels i18n (racha/aire/agua). */
5625
+ var _ts=function(k,fb){ return (typeof T==='function')?T(k,fb):fb; };
5528
5626
  statusBody.innerHTML=
5529
5627
  '<div>🌬 <b style="font-size:24px">'+Math.round(stWindKt)+' kt</b> <span style="color:#9ad">'+stDir+'</span>'+windBadge+'</div>'+
5530
- '<div>💨 racha <b style="font-size:21px">'+Math.round(stGustKt)+' kt</b>'+gustBadge+'</div>'+
5628
+ '<div>💨 '+_ts('sh_racha','racha')+' <b style="font-size:21px">'+Math.round(stGustKt)+' kt</b>'+gustBadge+'</div>'+
5531
5629
  '<div>'+waveLine+'</div>'+
5532
- '<div>🌡 aire <b style="font-size:20px">'+tempA+'</b>'+tempABadge+' &nbsp;&nbsp; 🌊 agua <b style="font-size:20px">'+tempW+'</b>'+tempWBadge+'</div>'+
5630
+ '<div>🌡 '+_ts('sh_aire','aire')+' <b style="font-size:20px">'+tempA+'</b>'+tempABadge+' &nbsp;&nbsp; 🌊 '+_ts('sh_agua','agua')+' <b style="font-size:20px">'+tempW+'</b>'+tempWBadge+'</div>'+
5533
5631
  pressureNumLine+
5534
5632
  sparkRow;
5535
5633
  /* Botón AHORA: resaltado si NO hay hora seleccionada */
@@ -5896,7 +5994,7 @@ function _wxMapMarkerUpdate(){
5896
5994
  if(_wDeg!==null){
5897
5995
  var wEdge=geoEdge(pos.lat,pos.lng,alarmR,_wDeg);
5898
5996
  var wDirTxt=_wxC16?_wxC16(_wDeg):'';
5899
- var wLabel='Viento '+(wDirTxt?wDirTxt+' ':'')+Math.round(_wKt)+' kt';
5997
+ var wLabel=((typeof T==='function')?T('m_viento','Viento'):'Viento')+' '+(wDirTxt?wDirTxt+' ':'')+Math.round(_wKt)+' kt';
5900
5998
  var html=_wxMarkerHtml('#ffeb3b',wLabel,_wDeg,'wind',_labOffW);
5901
5999
  var icon=L.divIcon({html:html,iconSize:[1,1],iconAnchor:[0,0],className:'wx-map-marker'});
5902
6000
  if(_wxMapMarkers.wind){_wxMapMarkers.wind.setIcon(icon);_wxMapMarkers.wind.setLatLng(wEdge);_wxMapMarkers.wind.setZIndexOffset(300);if(!map.hasLayer(_wxMapMarkers.wind))_wxMapMarkers.wind.addTo(map);}
@@ -5910,13 +6008,12 @@ function _wxMapMarkerUpdate(){
5910
6008
  var oEdge=geoEdge(pos.lat,pos.lng,alarmR,_vDirEff);
5911
6009
  var oDirTxt=_wxC16?_wxC16(_vDirEff):'';
5912
6010
  var oLabel;
6011
+ /* Rev315: labels "Olas" y "Mar en calma" i18n. */
6012
+ var _tw=function(k,fb){ return (typeof T==='function')?T(k,fb):fb; };
5913
6013
  if(_boatWave){
5914
- /* Rev128: si la ola es despreciable (intensidad calma), no marear con
5915
- direcciones y períodos cortos sin sentido — decir simplemente
5916
- "Mar en calma". Si hay movimiento medible, mostrar altura+dir+período. */
5917
6014
  var _vbCalm=(_boatWave.intensity==='calma')&&(typeof _boatWave.heightSigM!=='number'||_boatWave.heightSigM<0.05);
5918
6015
  if(_vbCalm){
5919
- oLabel='Mar en calma';
6016
+ oLabel=_tw('m_mar_calma','Mar en calma');
5920
6017
  }else{
5921
6018
  var _vbPer=(typeof _boatWave.periodSec==='number'&&_boatWave.periodSec>0)?Math.round(_boatWave.periodSec)+'s':'';
5922
6019
  var _vbMain;
@@ -5925,10 +6022,10 @@ function _wxMapMarkerUpdate(){
5925
6022
  }else{
5926
6023
  _vbMain=(_boatWave.intensity||'').charAt(0).toUpperCase()+(_boatWave.intensity||'').slice(1);
5927
6024
  }
5928
- oLabel='Olas '+(oDirTxt?oDirTxt+' ':'')+_vbMain+(_vbPer?' · '+_vbPer:'');
6025
+ oLabel=_tw('m_olas','Olas')+' '+(oDirTxt?oDirTxt+' ':'')+_vbMain+(_vbPer?' · '+_vbPer:'');
5929
6026
  }
5930
6027
  }else{
5931
- oLabel='Olas '+(oDirTxt?oDirTxt+' ':'')+h.waveHeightM.toFixed(1).replace('.',',')+' m'+(h.wavePeriodSec>0?' · '+Math.round(h.wavePeriodSec)+' s':'');
6028
+ oLabel=_tw('m_olas','Olas')+' '+(oDirTxt?oDirTxt+' ':'')+h.waveHeightM.toFixed(1).replace('.',',')+' m'+(h.wavePeriodSec>0?' · '+Math.round(h.wavePeriodSec)+' s':'');
5932
6029
  }
5933
6030
  var oHtml=_wxMarkerHtml('#4dd0ff',oLabel,_vDirEff,'wave',_labOffV);
5934
6031
  var oIcon=L.divIcon({html:oHtml,iconSize:[1,1],iconAnchor:[0,0],className:'wx-map-marker'});
@@ -6642,8 +6739,44 @@ function setAlarm(type,on){
6642
6739
  document.getElementById('alarm-sonda-st').textContent=T('activa');document.getElementById('alarm-sonda-st').style.color='var(--grn)';
6643
6740
  }
6644
6741
  });
6742
+ }else if(type==='weather'){
6743
+ /* Rev316: alarma meteo — frontend-only por ahora. Estado en localStorage
6744
+ y check periódico contra _shelterCache.assessment.hours (próximas 6 h).
6745
+ Si viento > 25 kt u ola > 1.5 m → setAlarmActive('weather', true). */
6746
+ _alarmWeatherEnabled = on;
6747
+ localStorage.setItem('ihm-weather-alarm', on?'1':'0');
6748
+ var st = document.getElementById('alarm-weather-st');
6749
+ if (st) {
6750
+ st.textContent = on ? T('activa','activa') : T('desactivada','desactivada');
6751
+ st.style.color = on ? 'var(--grn)' : '#666';
6752
+ }
6753
+ if (!on) {
6754
+ try { setAlarmActive('weather', false); } catch(_){}
6755
+ } else {
6756
+ try { _checkWeatherAlarm(); } catch(_){}
6757
+ }
6645
6758
  }
6646
6759
  }
6760
+ /* Rev316: estado y comprobación de alarma meteo (frontend-only).
6761
+ Mira las próximas 6h de _shelterCache.assessment.hours buscando viento>25kt
6762
+ u ola>1.5m. Se invoca tras tocar el switch y cada 5 min mientras esté on. */
6763
+ var _alarmWeatherEnabled = (localStorage.getItem('ihm-weather-alarm')==='1');
6764
+ function _checkWeatherAlarm(){
6765
+ if (!_alarmWeatherEnabled) return;
6766
+ try {
6767
+ var asm = (typeof _shelterCache!=='undefined' && _shelterCache) ? _shelterCache.assessment : null;
6768
+ if (!asm || !asm.hours || !asm.hours.length) return;
6769
+ var bad = false;
6770
+ for (var i=0; i<Math.min(6, asm.hours.length); i++){
6771
+ var hr = asm.hours[i];
6772
+ var w = hr.windKt || hr.exposureKt || 0;
6773
+ var wave = hr.waveM || 0;
6774
+ if (w > 25 || wave > 1.5) { bad = true; break; }
6775
+ }
6776
+ setAlarmActive('weather', bad);
6777
+ } catch(_){}
6778
+ }
6779
+ setInterval(_checkWeatherAlarm, 5*60*1000);
6647
6780
  /* ══════════════ CALC SONDA ══════════════ */
6648
6781
  /* ═══ SHARED INPUT STYLE (touch-friendly, wide for fingers) ═══ */
6649
6782
  /* ═══ SHARED INPUT STYLE (touch-friendly) ═══ */
@@ -8051,13 +8184,17 @@ function m_syncBottomBarGraphs(){
8051
8184
  var v = srcVal.textContent.trim();
8052
8185
  if (v && v !== '—') dstVal.textContent = v;
8053
8186
  }
8054
- /* Grade del abrigo: A=verde, B=verde claro, C=naranja, D=naranja oscuro, F=rojo */
8187
+ /* Grade del abrigo: A=verde, B=verde claro, C=naranja, D=naranja oscuro, F=rojo.
8188
+ Rev316: el % del donut leía un mapa hardcoded por letra (A=100, B=80, ...)
8189
+ que NO coincidía con el % real del shelter modal. Ahora lee el valor REAL
8190
+ (asm.scorePercent) del _shelterCache. Si no hay cache aún caemos al mapa
8191
+ como antes. */
8055
8192
  var srcGrade = document.getElementById('wx-shelter-grade');
8056
8193
  var dstGrade = document.getElementById('m-bb-grade');
8057
8194
  var dstDonut = document.getElementById('m-bb-donut');
8058
8195
  var dstDonutText = document.getElementById('m-bb-donut-text');
8059
8196
  var gradeColors = { 'A':'#3aa856','B':'#9ed02e','C':'#ffb23f','D':'#ff7043','F':'#f44336' };
8060
- var donutPct = { 'A':100, 'B':80, 'C':60, 'D':40, 'F':20 };
8197
+ var donutPctFallback = { 'A':100, 'B':80, 'C':60, 'D':40, 'F':20 };
8061
8198
  if (srcGrade && dstGrade){
8062
8199
  var g = (srcGrade.textContent.trim() || '—').toUpperCase();
8063
8200
  if (g && g !== '—' && g !== '?') {
@@ -8065,15 +8202,19 @@ function m_syncBottomBarGraphs(){
8065
8202
  var bgColor = gradeColors[g] || 'rgba(120,120,120,.35)';
8066
8203
  dstGrade.style.background = bgColor;
8067
8204
  dstGrade.style.color = '#fff';
8205
+ /* Pct real del cache shelter; fallback al mapa por letra si no hay. */
8206
+ var realPct = null;
8207
+ try {
8208
+ var _asm = (typeof _shelterCache !== 'undefined' && _shelterCache) ? _shelterCache.assessment : null;
8209
+ if (_asm && typeof _asm.scorePercent === 'number') realPct = Math.round(_asm.scorePercent);
8210
+ } catch(_){}
8211
+ if (realPct === null) realPct = donutPctFallback[g] || 0;
8068
8212
  if (dstDonut) {
8069
- var pct = donutPct[g] || 0;
8070
- dstDonut.setAttribute('stroke-dasharray', pct + ',100');
8213
+ dstDonut.setAttribute('stroke-dasharray', realPct + ',100');
8071
8214
  dstDonut.setAttribute('stroke', bgColor);
8072
8215
  }
8073
- /* Rev286: porcentaje (con %) dentro del donut, no letra. */
8074
8216
  if (dstDonutText) {
8075
- var p = donutPct[g] || 0;
8076
- dstDonutText.textContent = p + '%';
8217
+ dstDonutText.textContent = realPct + '%';
8077
8218
  }
8078
8219
  }
8079
8220
  }
@@ -9033,12 +9174,14 @@ function m_renderMeteoWindy(d, seaD){
9033
9174
  var nowColW = (nowWindKt!=null) ? wColor(nowWindKt) : '#fff';
9034
9175
  var nowColG = (nowGustKt!=null) ? wColor(nowGustKt) : '#fff';
9035
9176
  /* Rev238: panel AHORA — sin iconos en labels (limpio). */
9177
+ /* Rev315: AHORA panel — labels i18n. */
9178
+ var _tn = function(k,fb){ return (typeof T==='function')?T(k,fb):fb; };
9036
9179
  var nowPanelHtml =
9037
9180
  '<div style="display:flex;flex-direction:column;height:100%;background:var(--bg);border:3px solid var(--org);box-sizing:border-box">' +
9038
- '<div style="background:var(--org);color:#000;font-size:22px;font-weight:900;text-align:center;padding:4px 4px;letter-spacing:2px;line-height:1">AHORA</div>' +
9181
+ '<div style="background:var(--org);color:#000;font-size:22px;font-weight:900;text-align:center;padding:4px 4px;letter-spacing:2px;line-height:1">'+_tn('m_ahora','AHORA')+'</div>' +
9039
9182
  '<div style="display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;flex:1;gap:0;color:#fff;text-align:center">' +
9040
9183
  '<div style="border-right:1px solid rgba(255,255,255,.4);border-bottom:1px solid rgba(255,255,255,.4);padding:4px 4px;display:flex;flex-direction:column;justify-content:center">' +
9041
- '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">VIENTO'+srcBadge(srcWind)+'</div>' +
9184
+ '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">'+_tn('met_viento','VIENTO').toUpperCase()+srcBadge(srcWind)+'</div>' +
9042
9185
  '<div style="font-size:32px;font-weight:900;color:#fff;line-height:1.05;display:flex;align-items:center;justify-content:center;gap:4px">'+
9043
9186
  '<span>'+fmtKt(nowWindKt)+'</span>'+
9044
9187
  '<span style="font-size:14px;color:#9aa;font-weight:700">kt</span>'+
@@ -9046,15 +9189,15 @@ function m_renderMeteoWindy(d, seaD){
9046
9189
  '</div>' +
9047
9190
  '</div>' +
9048
9191
  '<div style="border-bottom:1px solid rgba(255,255,255,.4);padding:4px 4px;display:flex;flex-direction:column;justify-content:center">' +
9049
- '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">RACHA'+srcBadge(srcGust)+'</div>' +
9192
+ '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">'+_tn('met_racha','RACHA').toUpperCase()+srcBadge(srcGust)+'</div>' +
9050
9193
  '<div style="font-size:32px;font-weight:900;color:#fff;line-height:1.05">'+fmtKt(nowGustKt)+'<span style="font-size:14px;color:#9aa;font-weight:700"> kt</span></div>' +
9051
9194
  '</div>' +
9052
9195
  '<div style="border-right:1px solid rgba(255,255,255,.4);padding:4px 4px;display:flex;flex-direction:column;justify-content:center">' +
9053
- '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">AIRE'+srcBadge(srcAir)+'</div>' +
9196
+ '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">'+_tn('met_aire','AIRE').toUpperCase()+srcBadge(srcAir)+'</div>' +
9054
9197
  '<div style="font-size:32px;font-weight:900;color:#fff;line-height:1.05">'+fmtT(nowTAir)+'</div>' +
9055
9198
  '</div>' +
9056
9199
  '<div style="padding:4px 4px;display:flex;flex-direction:column;justify-content:center">' +
9057
- '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">MAR'+srcBadge(srcSea)+'</div>' +
9200
+ '<div style="font-size:16px;color:var(--org);font-weight:700;letter-spacing:1px;line-height:1">'+_tn('met_mar','MAR').toUpperCase()+srcBadge(srcSea)+'</div>' +
9058
9201
  '<div style="font-size:32px;font-weight:900;color:#fff;line-height:1.05">'+fmtT(nowTSea)+'</div>' +
9059
9202
  '</div>' +
9060
9203
  '</div>' +
@@ -9203,32 +9346,27 @@ function m_renderMeteoWindy(d, seaD){
9203
9346
  if (kn == null) return '–';
9204
9347
  return '<span style="color:'+wColor(kn)+';font-size:32px">'+kn.toFixed(0)+'</span>';
9205
9348
  });
9206
- /* Aire */
9207
- if (h.temperature_2m) rows += rowMetric('Aire','🌡️','#ffb23f','°C',
9349
+ /* Rev315: row labels i18n. */
9350
+ var _tr=function(k,fb){ return (typeof T==='function')?T(k,fb):fb; };
9351
+ if (h.temperature_2m) rows += rowMetric(_tr('met_row_aire','Aire'),'🌡️','#ffb23f','°C',
9208
9352
  function(col){ return getVal(h.temperature_2m, col); },
9209
9353
  function(v){ return v==null?'–':'<span style="font-size:30px">'+v.toFixed(0)+'°</span>'; });
9210
- /* Presión */
9211
- if (h.pressure_msl || h.surface_pressure) rows += rowMetric('Presión','🔵','#9ad','mbar',
9354
+ if (h.pressure_msl || h.surface_pressure) rows += rowMetric(_tr('met_row_presion','Presión'),'🔵','#9ad','mbar',
9212
9355
  function(col){ return getVal(h.pressure_msl || h.surface_pressure, col); },
9213
9356
  function(v){ return v==null?'–':'<span style="font-size:26px">'+Math.round(v)+'</span>'; });
9214
- /* Lluvia */
9215
- if (h.precipitation || h.rain) rows += rowMetric('Lluvia','💧','#4dd0ff','mm',
9357
+ if (h.precipitation || h.rain) rows += rowMetric(_tr('met_row_lluvia','Lluvia'),'💧','#4dd0ff','mm',
9216
9358
  function(col){ return getVal(h.precipitation || h.rain, col); },
9217
9359
  function(v){ return (v==null||v===0)?'–':'<span style="font-size:28px">'+v.toFixed(1)+'</span>'; });
9218
- /* T. Mar */
9219
- if (seaH.sea_surface_temperature) rows += rowMetric('T. Mar','🐟','#4dd0ff','°C',
9360
+ if (seaH.sea_surface_temperature) rows += rowMetric(_tr('met_row_tmar','T. Mar'),'🐟','#4dd0ff','°C',
9220
9361
  function(col){ return getSeaVal(seaH.sea_surface_temperature, col); },
9221
9362
  function(v){ return v==null?'–':'<span style="font-size:30px">'+v.toFixed(0)+'°</span>'; });
9222
- /* Ola altura */
9223
- if (seaH.wave_height) rows += rowMetric('Ola','🌊','#4dd0ff','m',
9363
+ if (seaH.wave_height) rows += rowMetric(_tr('met_row_ola','Ola'),'🌊','#4dd0ff','m',
9224
9364
  function(col){ return getSeaVal(seaH.wave_height, col); },
9225
9365
  function(v){ return v==null?'–':'<span style="font-size:30px">'+v.toFixed(1)+'</span>'; });
9226
- /* Período */
9227
- if (seaH.wave_period) rows += rowMetric('Período','⏱️','#9ad','s',
9366
+ if (seaH.wave_period) rows += rowMetric(_tr('met_row_periodo','Período'),'⏱️','#9ad','s',
9228
9367
  function(col){ return getSeaVal(seaH.wave_period, col); },
9229
9368
  function(v){ return v==null?'–':'<span style="font-size:28px">'+Math.round(v)+'</span>'; });
9230
- /* Dir ola número de grados + flecha rotada pequeña (Rev228). */
9231
- if (seaH.wave_direction) rows += rowMetric('Dir ola','🧭','#4dd0ff','°',
9369
+ if (seaH.wave_direction) rows += rowMetric(_tr('met_row_dirola','Dir ola'),'🧭','#4dd0ff','°',
9232
9370
  function(col){ return getSeaVal(seaH.wave_direction, col); },
9233
9371
  function(v){
9234
9372
  if (v == null) return '–';
@@ -9338,7 +9476,7 @@ function m_renderMeteoWindy(d, seaD){
9338
9476
  try { collapsed = localStorage.getItem('m-met-summary-collapsed') === '1'; } catch(_){}
9339
9477
  return '<div id="m-met-summary" class="' + (collapsed ? 'collapsed' : '') + '" style="background:rgba(20,40,60,.7);border-top:2px solid var(--org);color:#dde;flex-shrink:0">' +
9340
9478
  '<button id="m-met-summary-toggle" onclick="m_toggleMeteoSummary()" style="display:flex;align-items:center;justify-content:space-between;width:100%;background:none;border:none;color:var(--org);font-size:28px;font-weight:900;padding:18px 24px;cursor:pointer;text-align:left">' +
9341
- '<span>📰 Resumen</span>' +
9479
+ '<span>📰 '+((typeof T==='function')?T('met_resumen','Resumen'):'Resumen')+'</span>' +
9342
9480
  '<span id="m-met-summary-chev" style="font-size:24px">' + (collapsed ? '▼' : '▲') + '</span>' +
9343
9481
  '</button>' +
9344
9482
  '<div id="m-met-summary-body" style="padding:0 24px 18px;font-size:24px;line-height:1.55;' + (collapsed ? 'display:none' : '') + '">' +