jorge@JDServer:/var/www/html/jdserver/2026/modules/map$ sudo cat map.js
/*! JDServer-Webs — Stations Map
 * Lee /2026/data/stations.json y pinta markers con Leaflet
 * - Tiles SIEMPRE en modo claro
 * - Popup con datos básicos desde /2026/data/[slug]/latest.json
 * - Pan automático para que el popup no se corte
 * - Toque de color por estación
 */

(async function () {
  const mapEl = document.getElementById('jdStationsMap');
  if (!mapEl || !window.L) return;

  const i18n = (k, fb) =>
    (window.JD && JD.i18n && JD.i18n.dict?.[document.documentElement.lang || 'es']?.[k]) || fb || k;

  const searchEl = document.getElementById('jdMapSearch');
  const resetEl  = document.getElementById('jdMapReset');
  const legendEl = document.getElementById('jdMapLegend');

  const DATA_URL = '../../data/stations.json'; // relativo desde /sites/<slug>/
  const LATEST_BASE = '../../data';            // ../../data/<slug>/latest.json

  // --- Helpers tema (solo para estilo de borde del marker, NO para tiles) ---
  function isDarkUi() {
    const t = document.documentElement.getAttribute('data-theme');
    if (t === 'dark') return true;
    if (t === 'light') return false;
    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
  }

  // --- Tiles SIEMPRE claros ---
  function makeTileLayer() {
    const url = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
    const attr = '&copy; OpenStreetMap &copy; CARTO';
    return L.tileLayer(url, { attribution: attr, maxZoom: 18 });
  }

  // --- Formateo datos ---
  function safeNum(v) {
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  }
  function windDirPretty(dir) {
    if (!dir) return '';
    return String(dir);
  }
  function mm(v) {
    const n = safeNum(v);
    if (n === null) return null;
    return n.toFixed(1);
  }
  function c(v) {
    const n = safeNum(v);
    if (n === null) return null;
    return n.toFixed(1);
  }
  function kmh(v) {
    const n = safeNum(v);
    if (n === null) return null;
    return n.toFixed(1);
  }

  // --- Cache de latest por slug ---
  const latestCache = new Map(); // slug -> { ok:boolean, ts:number, data:any }
  const LATEST_TTL_MS = 60_000;  // 1 min

  async function fetchLatest(slug) {
    if (!slug) return null;

    const now = Date.now();
    const cached = latestCache.get(slug);
    if (cached && (now - cached.ts) < LATEST_TTL_MS) {
      return cached.ok ? cached.data : null;
    }

    const url = `${LATEST_BASE}/${encodeURIComponent(slug)}/latest.json`;

    try {
      const res = await fetch(url, { cache: 'no-store' });
      if (!res.ok) throw new Error('latest not ok');
      const data = await res.json();
      latestCache.set(slug, { ok: true, ts: now, data });
      return data;
    } catch (e) {
      latestCache.set(slug, { ok: false, ts: now, data: null });
      return null;
    }
  }

  // Tarjetitas: valor + etiqueta (evita solapes)
  function metricBox(value, label) {
    return `
      <div class="jd-metric">
        <div class="v">${value}</div>
        <div class="k">${label}</div>
      </div>
    `.trim();
  }

  function latestHtml(latest, station) {
    if (!latest || !latest.dades_act) return '';

    const a = latest.dades_act;

    const temp = c(a.TEMP);
    const hum  = safeNum(a.HUM);
    const vel  = kmh(a.VEL);
    const dir  = windDirPretty(a.DIR);
    const bar  = c(a.BAR);
    const prec = mm(a.PREC);
    const hora = a.HORA || '';

    const items = [];

    if (temp !== null) items.push(metricBox(`${temp}°C`, i18n('map_temp','Temp')));
    if (hum !== null)  items.push(metricBox(`${hum}%`, i18n('map_hum','Hum')));
    if (vel !== null)  items.push(metricBox(`${vel}`, `${i18n('map_wind','Vent')}${dir ? ' ' + dir : ''}`));
    if (bar !== null)  items.push(metricBox(`${bar}`, i18n('map_pres','hPa')));
    if (prec !== null) items.push(metricBox(`${prec}`, i18n('map_rain','mm')));

    if (!items.length) return '';

    const color = (station && (station.primary || station.accent)) || '#12bdb3';

    return `
      <div class="jd-map-live" style="--st:${color}">
        <div class="jd-map-live-grid">
          ${items.join('')}
        </div>
        ${hora ? `<div class="jd-map-live-upd">${i18n('map_updated','Actualitzat')}: <b>${hora}</b></div>` : ``}
      </div>
    `.trim();
  }

  function popupShell(s) {
    const href = `../${s.slug}/index.html`;
    const color = (s.primary || s.accent || '#12bdb3');
    return `
      <div class="jd-map-popup" style="--st:${color}">
        <div class="jd-map-pop-top"></div>
        <div class="title">${s.display || s.slug}</div>
        <div class="meta">${s.city || ''}</div>
        <div class="live-slot" data-live="1">
          <div class="jd-map-loading">${i18n('map_loading','Carregant dades…')}</div>
        </div>
        <a class="jd-map-open" href="${href}">↗ ${i18n('map_open_station','Obrir estació')}</a>
      </div>
    `.trim();
  }

  // --- Crear mapa ---
  const map = L.map(mapEl, {
    zoomControl: true,
    scrollWheelZoom: true,
  });

  let tileLayer = makeTileLayer();
  tileLayer.addTo(map);

  // Grupo markers
  const markers = L.layerGroup().addTo(map);
  let allStations = [];
  let stationMarkers = [];

  // Pan para evitar que el popup quede cortado por arriba
  function panToShowPopup(popup) {
    try {
      const pEl = popup && popup.getElement ? popup.getElement() : null;
      if (!pEl) return;

      // contenedor del mapa en pantalla
      const mapRect = mapEl.getBoundingClientRect();
      const popRect = pEl.getBoundingClientRect();

      // margen superior “de seguridad”
      const SAFE_TOP = 18;

      // si el popup se sale por arriba del mapa, bajamos (pan hacia abajo => panBy y negativo)
      const overflowTop = (mapRect.top + SAFE_TOP) - popRect.top; // positivo si se corta
      if (overflowTop > 0) {
        // panBy: y positivo mueve el mapa hacia abajo visualmente? Leaflet usa pixels en container coords.
        // Queremos “bajar” el popup => mover mapa hacia abajo => panBy(0, -overflowTop)
        map.panBy([0, -Math.ceil(overflowTop)], { animate: true, duration: 0.35 });
      }
    } catch (_) {}
  }

  function addStations(list) {
    markers.clearLayers();
    stationMarkers = [];

    list.forEach(s => {
      if (!s.coords || typeof s.coords.lat !== 'number' || typeof s.coords.lon !== 'number') return;

      const m = L.circleMarker([s.coords.lat, s.coords.lon], {
        radius: 7,
        weight: 2,
        color: isDarkUi() ? 'rgba(255,255,255,.85)' : 'rgba(0,0,0,.55)',
        fillColor: s.primary || '#12bdb3',
        fillOpacity: 0.9
      });

      m.bindPopup(popupShell(s), {
        maxWidth: 310,
        autoPan: false,   // lo hacemos nosotros (más suave y controlado)
        closeButton: true
      });

      // Al abrir popup: pan + cargar latest.json
      m.on('popupopen', async (ev) => {
        const popup = ev.popup;

        // 1) centrar suavemente el marker (sin cambiar zoom)
        map.panTo(m.getLatLng(), { animate: true, duration: 0.35 });

        // 2) cuando Leaflet pinte el popup, corregimos si se corta por arriba
        requestAnimationFrame(() => {
          panToShowPopup(popup);
        });

        const el = popup && popup.getElement ? popup.getElement() : null;
        if (!el) return;

        const slot = el.querySelector('.live-slot[data-live="1"]');
        if (!slot) return;

        if (slot.getAttribute('data-loaded') === '1') return;

        const latest = await fetchLatest(s.slug);
        if (!latest) {
          slot.innerHTML = `<div class="jd-map-loading">${i18n('map_no_live','Sense dades en directe')}</div>`;
          slot.setAttribute('data-loaded', '1');
          return;
        }

        slot.innerHTML = latestHtml(latest, s) || `<div class="jd-map-loading">${i18n('map_no_live','Sense dades en directe')}</div>`;
        slot.setAttribute('data-loaded', '1');

        // por si el popup crece al meter datos, re-pan
        requestAnimationFrame(() => {
          panToShowPopup(popup);
        });
      });

      m.addTo(markers);
      stationMarkers.push({ s, m });
    });

    // Encajar bounds si hay datos
    const latlngs = stationMarkers.map(x => x.m.getLatLng());
    if (latlngs.length) {
      const b = L.latLngBounds(latlngs);
      map.fitBounds(b.pad(0.18));
    } else {
      map.setView([40.4, -3.7], 5);
    }
  }

  function filterStations(q) {
    const t = (q || '').trim().toLowerCase();
    if (!t) return allStations;
    return allStations.filter(s => {
      const a = (s.slug || '').toLowerCase();
      const b = (s.city || '').toLowerCase();
      const c = (s.display || '').toLowerCase();
      return a.includes(t) || b.includes(t) || c.includes(t);
    });
  }

  // Cargar datos
  try {
    const res = await fetch(DATA_URL, { cache: 'no-store' });
    if (!res.ok) throw new Error('stations.json not found');
    allStations = await res.json();
  } catch (e) {
    legendEl && (legendEl.textContent = 'No s’han pogut carregar les estacions (stations.json).');
    map.setView([40.4, -3.7], 5);
    return;
  }

  addStations(allStations);
  if (legendEl) legendEl.textContent = `${allStations.length} ${i18n('map_stations','estacions actives')}`;

  // Buscar
  if (searchEl) {
    searchEl.addEventListener('input', () => {
      const list = filterStations(searchEl.value);
      addStations(list);
      if (legendEl) legendEl.textContent = `${list.length} ${i18n('map_stations','estacions')}`;
    });
  }

  // Reset
  if (resetEl) {
    resetEl.addEventListener('click', () => {
      if (searchEl) searchEl.value = '';
      addStations(allStations);
      if (legendEl) legendEl.textContent = `${allStations.length} ${i18n('map_stations','estacions actives')}`;
    });
  }

  // Si cambiamos tema en caliente: SOLO recolorear borde del marker (tiles se quedan claros)
  function refreshTheme() {
    stationMarkers.forEach(({s, m}) => {
      m.setStyle({
        color: isDarkUi() ? 'rgba(255,255,255,.85)' : 'rgba(0,0,0,.55)',
        fillColor: s.primary || '#12bdb3'
      });
    });
  }

  window.addEventListener('jd:theme', refreshTheme);

  // por si theme.js no emite evento todavía, también vigilamos atributo
  const mo = new MutationObserver((mut) => {
    for (const m of mut) {
      if (m.type === 'attributes' && m.attributeName === 'data-theme') refreshTheme();
    }
  });
  mo.observe(document.documentElement, { attributes: true });

})();
jorge@JDServer:/var/www/html/jdserver/2026/modules/map$ sudo cat map.css
/* JDServer-Webs — Mapa de estaciones (Leaflet) */

#jd-map-module { overflow: hidden; }

.jd-map-toolbar{
  display:flex;
  gap:10px;
  align-items:center;
  margin: 10px 0 12px;
}

.jd-map-search{
  flex:1;
  height: 40px;
  border-radius: 999px;
  padding: 0 14px;
  border: 1px solid var(--border, rgba(0,0,0,.12));
  background: var(--card, rgba(255,255,255,.7));
  color: var(--text, #111);
  font-weight: 600;
}

html[data-theme="dark"] .jd-map-search{
  border-color: var(--border, #2b2f36);
  background: var(--card, #111418);
  color: var(--text, #dfe7ef);
}

.jd-map-btn{
  height: 40px;
  padding: 0 14px;
  border-radius: 999px;
  border: 1px solid var(--border, rgba(0,0,0,.12));
  background: var(--card, rgba(255,255,255,.7));
  color: var(--text, #111);
  font-weight: 800;
  cursor: pointer;
}

html[data-theme="dark"] .jd-map-btn{
  border-color: var(--border, #2b2f36);
  background: var(--card, #111418);
  color: var(--text, #dfe7ef);
}

.jd-stations-map{
  height: clamp(360px, 55vh, 620px);
  border-radius: 18px;
  border: 1px solid var(--border, rgba(0,0,0,.12));
  overflow:hidden;
  background: var(--card, #fff);
}

html[data-theme="dark"] .jd-stations-map{
  border-color: var(--border, #2b2f36);
  background: var(--card, #111418);
}

/* Leaflet: que encaje con temas */
.leaflet-control-attribution{
  font-size: 11px;
  opacity: .85;
}

/* ===== Popup: evitar estrechamientos + evitar desbordes ===== */
.leaflet-popup-content,
.leaflet-popup-content *{
  box-sizing: border-box; /* ✅ clave anti “se sale por la derecha” */
}

.leaflet-popup-content{
  margin: 14px 14px 12px;
  min-width: 260px;     /* ✅ un pelín más ancho para que el grid respire */
  max-width: 340px;
  width: auto;
}

@media (max-width: 380px){
  .leaflet-popup-content{
    min-width: 240px;
  }
}

/* Popup estilo nuestro */
.jd-map-popup{
  --st: #12bdb3;
}

.jd-map-pop-top{
  height: 6px;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--st), rgba(0,0,0,0));
  margin-bottom: 10px;
  opacity: .95;
}

.jd-map-popup .title{
  font-weight: 950;
  font-size: 18px;
  margin: 0 0 4px;
}

.jd-map-popup .meta{
  opacity: .85;
  font-weight: 750;
  margin: 0 0 10px;
}

.jd-map-loading{
  opacity:.7;
  font-weight:800;
}

.jd-map-open{
  display:inline-flex;
  gap:8px;
  align-items:center;
  text-decoration:none;
  font-weight: 900;
  color: var(--brand, #12bdb3);
  margin-top: 6px;
}

.jd-map-legend{
  margin-top: 10px;
  font-size: 13px;
  opacity: .85;
  font-weight: 700;
}

/* ===== Live mini-card dentro del popup ===== */
.jd-map-live{
  margin: 10px 0 10px;
  padding: 10px 10px;
  border-radius: 14px;
  border: 1px solid color-mix(in srgb, var(--st) 40%, rgba(0,0,0,.12));
  background: rgba(255,255,255,.92);
  box-shadow: 0 10px 24px rgba(0,0,0,.10);
  max-width: 100%;
  overflow: hidden; /* ✅ si algún navegador se pone tonto, lo corta aquí */
}

html[data-theme="dark"] .jd-map-live{
  border-color: color-mix(in srgb, var(--st) 35%, rgba(255,255,255,.14));
  background: rgba(17,20,24,.78);
  box-shadow: 0 10px 26px rgba(0,0,0,.35);
}

/* ✅ Grid 2 columnas “blindado”: nunca empuja fuera */
.jd-map-live-grid{
  display:grid;
  grid-template-columns: repeat(2, minmax(0, 1fr)); /* ✅ min=0 evita overflow lateral */
  gap: 10px;
  width: 100%;
}

/* en móviles ultra estrechos, 1 columna */
@media (max-width: 360px){
  .jd-map-live-grid{
    grid-template-columns: 1fr;
  }
}

/* Tarjetitas */
.jd-map-live-grid .jd-metric{
  min-width: 0;                 /* ✅ permite encoger */
  padding: 9px 8px;             /* ✅ un pelín más estrechas */
  border-radius: 12px;
  background: rgba(0,0,0,.04);
  text-align: center;
  border: 1px solid rgba(0,0,0,.06);
}

html[data-theme="dark"] .jd-map-live-grid .jd-metric{
  background: rgba(255,255,255,.06);
  border-color: rgba(255,255,255,.08);
}

.jd-map-live-grid .jd-metric .v{
  display:block;
  font-size: 15px;
  font-weight: 950;
  letter-spacing: -.2px;
  line-height: 1.05;
  white-space: nowrap;          /* ✅ evita que “9.7°C” parta raro */
}

.jd-map-live-grid .jd-metric .k{
  display:block;
  margin-top: 3px;
  font-size: 11px;
  font-weight: 850;
  opacity: .82;
  line-height: 1.1;
}

.jd-map-live-upd{
  margin-top: 8px;
  font-size: 11px;
  font-weight: 850;
  opacity: .88;
  text-align: right;
}

/* Si tenemos 5 métricas, la última (lluvia) ocupa 2 columnas */
.jd-map-live-grid .jd-metric:last-child:nth-child(5){
  grid-column: 1 / -1;
}
