File size: 6,674 Bytes
4ab46a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/* ============================================================
   Monitor de Condiciones de Observación — cliente
   Analiza los datos enteramente en el navegador (Pyodide + pandas/numpy),
   sin backend. Las mini-gráficas son SVG a mano (sin librerías).
   ============================================================ */
"use strict";

const ESTADO = {
  ok:      { titulo: "Apto para observar", sub: "Condiciones dentro de límites." },
  aviso:   { titulo: "Precaución",         sub: "Condiciones a vigilar de cerca." },
  critico: { titulo: "No apto",            sub: "Fuera de límites: valorar cerrar la cúpula." },
};
const ORDEN_SENSORES = ["humedad", "viento", "margen_rocio", "temp_ambiente", "punto_rocio", "presion"];

const $ = (s) => document.querySelector(s);

function reloj() {
  return new Date().toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });
}

/* -------- Mini-gráfica SVG -------- */
function sparkline(valores, anomalias, aviso, critico, color) {
  const w = 160, h = 42, pad = 3;
  const min = Math.min(...valores), max = Math.max(...valores);
  const rango = (max - min) || 1;
  const x = (i) => pad + (i / (valores.length - 1)) * (w - 2 * pad);
  const y = (v) => h - pad - ((v - min) / rango) * (h - 2 * pad);
  const pts = valores.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
  const linea = (val, col) => (val != null && val >= min && val <= max)
    ? `<line x1="0" y1="${y(val).toFixed(1)}" x2="${w}" y2="${y(val).toFixed(1)}"
             stroke="${col}" stroke-width="1" stroke-dasharray="3 3" opacity="0.5"/>` : "";
  const marcas = anomalias.map((a, i) => a
    ? `<circle cx="${x(i).toFixed(1)}" cy="${y(valores[i]).toFixed(1)}" r="2.3"
               fill="none" stroke="#E5484D" stroke-width="1.2"/>` : "").join("");
  return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" role="img" aria-label="tendencia">
    ${linea(aviso, "#E0A83E")}${linea(critico, "#E5484D")}
    <polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.6"
              stroke-linejoin="round" stroke-linecap="round"/>${marcas}</svg>`;
}

function pintar(data) {
  document.body.dataset.nivel = data.nivel_global;
  const info = ESTADO[data.nivel_global];
  $("#reloj").textContent = `${data.fuente} · ${reloj()}`;
  $("#estadoTitulo").textContent = info.titulo;
  $("#estadoSub").textContent = data.n_incidencias > 0
    ? `${data.n_incidencias} aviso(s) · ${info.sub}` : info.sub;
  $("#fuenteNombre").textContent = data.fuente || "";
  $("#nMuestras").textContent = data.n_muestras;
  $("#avisos").textContent = (data.avisos && data.avisos.length) ? data.avisos.join("  ·  ") : "";

  // Avisos / incidencias
  const cont = $("#incidencias");
  $("#contadorInc").textContent = data.n_incidencias;
  if (!data.incidencias.length) {
    cont.innerHTML = `<p class="vacio">Ninguno. Condiciones aptas para observar.</p>`;
  } else {
    cont.innerHTML = data.incidencias.map((i) => {
      const eta = i.eta_h ? `<span class="incidencia__eta">límite en ~${(+i.eta_h).toFixed(1)} h</span>` : "";
      const etq = (i.sensor === "margen_rocio") ? "margen de rocío" : i.sensor.replace(/_/g, " ");
      return `<article class="incidencia" data-nivel="${i.nivel}">
        <div class="incidencia__cab">
          <span class="incidencia__sensor">${etq}</span>
          <span class="incidencia__valor">${(+i.valor).toFixed(2)} ${i.unidad}</span>
        </div>${eta}
        <p><b>Motivo:</b> ${i.causa}</p>
        <p><b>Acción:</b> ${i.accion}</p>
      </article>`;
    }).join("");
  }

  // Sensores
  const sens = $("#sensores");
  sens.innerHTML = ORDEN_SENSORES.filter((s) => data.sensores[s]).map((s) => {
    const d = data.sensores[s];
    const color = d.nivel === "critico" ? "#E5484D" : d.nivel === "aviso" ? "#E0A83E" : "#3FB68B";
    return `<div class="sensor" data-nivel="${d.nivel}">
      <div class="sensor__top"><span class="sensor__nom">${d.etiqueta}</span></div>
      <div class="sensor__val">${d.actual}<small> ${d.unidad}</small></div>
      ${sparkline(d.valores, d.anomalias, d.aviso, d.critico, color)}
    </div>`;
  }).join("");

  $("#informe").textContent = data.informe;
}

/* -------- Motor de análisis en el navegador (Pyodide) -------- */
let pyodideListo = null;

async function iniciarPyodide() {
  if (pyodideListo) return pyodideListo;
  pyodideListo = (async () => {
    const pyodide = await loadPyodide();
    await pyodide.loadPackage(["numpy", "pandas"]);
    for (const nombre of ["conditions_core.py", "logic.py"]) {
      const texto = await (await fetch(`./${nombre}`)).text();
      pyodide.FS.writeFile(nombre, texto);
    }
    await pyodide.runPythonAsync("import logic");
    return pyodide;
  })();
  return pyodideListo;
}

async function analizarTexto(csvTexto, nombre) {
  const pyodide = await iniciarPyodide();
  pyodide.globals.set("_csv_texto", csvTexto);
  pyodide.globals.set("_nombre", nombre);
  const resultadoJson = nombre === null
    ? pyodide.runPython("logic.analizar_muestra(_csv_texto)")
    : pyodide.runPython("logic.analizar_subida(_csv_texto, _nombre)");
  return JSON.parse(resultadoJson);
}

async function cargarMuestra() {
  marcarFuente("btnMuestra");
  $("#estadoTitulo").textContent = "Cargando…";
  try {
    const csvTexto = await (await fetch("./data/muestra_condiciones.csv")).text();
    const data = await analizarTexto(csvTexto, null);
    if (data.error) throw new Error(data.error);
    pintar(data);
  } catch (e) { fallo(); }
}

async function subirCsv(file) {
  $("#estadoTitulo").textContent = "Analizando CSV…";
  try {
    const csvTexto = await file.text();
    const data = await analizarTexto(csvTexto, file.name);
    if (data.error) throw new Error(data.error);
    marcarFuente(null);
    pintar(data);
  } catch (e) {
    $("#estadoTitulo").textContent = "CSV no válido";
    $("#estadoSub").textContent = e.message || "Revisa el fichero e inténtalo de nuevo.";
  }
}

function marcarFuente(id) {
  $("#btnMuestra").setAttribute("aria-pressed", id === "btnMuestra");
}
function fallo() {
  $("#estadoTitulo").textContent = "Sin datos";
  $("#estadoSub").textContent = "No se pudo cargar. Pulsa Actualizar.";
}

$("#btnMuestra").addEventListener("click", cargarMuestra);
$("#btnActualizar").addEventListener("click", () => {
  const f = $("#fileCsv").files[0];
  f ? subirCsv(f) : cargarMuestra();
});
$("#fileCsv").addEventListener("change", (e) => {
  if (e.target.files[0]) subirCsv(e.target.files[0]);
});

cargarMuestra();

if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {}));
}