You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

705 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Micro Air Quality Station</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
/* Estilo general con gradiente sutil de fondo */
body {
background: linear-gradient(135deg, #f5f5f7 0%, #e8e8ed 100%);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1d1d1f;
margin: 0;
padding: 40px 20px;
min-height: 100vh;
}
h1 {
font-weight: 700;
letter-spacing: -1px;
font-size: 2.5rem;
margin-bottom: 5px;
background: linear-gradient(90deg, #1d1d1f, #434344);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
h2 {
font-weight: 600;
letter-spacing: -0.5px;
color: #1d1d1f;
}
.contenedor {
max-width: 1200px;
margin: 0 auto;
}
/* Franja de Color NOM-172 con efecto premium */
#franja-ica {
padding: 24px;
border-radius: 24px;
margin-bottom: 40px;
text-align: center;
color: white;
font-weight: 700;
font-size: 24px;
background: linear-gradient(135deg, #86868b, #6e6e73);
transition: background 0.5s ease, box-shadow 0.3s ease;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
letter-spacing: 0.5px;
}
/* Diseño de las Tarjetas (Cards) con profundidad multicapa */
.grid-tarjetas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
margin-bottom: 50px;
}
.grid-graficas {
display: grid;
grid-template-columns: 1fr;
gap: 40px;
margin-bottom: 50px;
}
.tarjeta, .tarjeta-grafica {
background: linear-gradient(145deg, #ffffff, #fdfdfd);
border-radius: 24px;
padding: 28px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(255, 255, 255, 0.8);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
overflow: hidden;
}
.tarjeta:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08), 0 2px 5px rgba(0, 0, 0, 0.03);
}
/* Acento decorativo sutil en las tarjetas */
.tarjeta::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, transparent, rgba(0, 113, 227, 0.1), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.tarjeta:hover::before { opacity: 1; }
.titulo-tarjeta {
font-size: 15px;
font-weight: 600;
color: #86868b;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.valor-tarjeta {
font-size: 46px;
font-weight: 700;
color: #1d1d1f;
display: flex;
align-items: baseline;
gap: 8px;
letter-spacing: -1.5px;
}
.unidad {
font-size: 18px;
font-weight: 600;
color: #a1a1a6;
letter-spacing: normal;
}
.error-desconectado {
font-size: 16px;
color: #ff3b30;
font-weight: 600;
background: rgba(255, 59, 48, 0.1);
padding: 8px 12px;
border-radius: 12px;
}
/* Elementos de formulario y botones modernizados */
.controles-superiores {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 15px;
background: rgba(255, 255, 255, 0.6);
padding: 15px 25px;
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
}
select, input[type="date"] {
padding: 10px 16px;
border-radius: 12px;
border: 1px solid #d2d2d7;
background-color: #ffffff;
font-family: inherit;
font-size: 14px;
font-weight: 500;
outline: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.02);
}
select:focus, input[type="date"]:focus {
border-color: #0071e3;
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.2);
}
button {
padding: 10px 20px;
border-radius: 20px;
border: none;
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 6px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
}
.btn-buscar { background: linear-gradient(135deg, #0071e3, #005bb5); color: white; font-weight: 600; }
.btn-csv { background: linear-gradient(135deg, #34c759, #28a745); color: white; font-weight: 600; }
.btn-excel { background: linear-gradient(135deg, #107c41, #0c5e31); color: white; font-weight: 600; }
/* Estilos de la Miniterminal estilo macOS */
.tarjeta-terminal {
background-color: #1e1e1e;
border-radius: 12px;
padding: 0;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3), 0 5px 15px rgba(0, 0, 0, 0.2);
margin-bottom: 50px;
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', Courier, monospace;
overflow: hidden;
border: 1px solid #333;
}
.terminal-header {
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);
padding: 10px 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #111;
}
.terminal-dots {
display: flex;
gap: 8px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.dot.red { background-color: #ff5f56; box-shadow: inset 0 0 4px rgba(0,0,0,0.2); }
.dot.yellow { background-color: #ffbd2e; box-shadow: inset 0 0 4px rgba(0,0,0,0.2); }
.dot.green { background-color: #27c93f; box-shadow: inset 0 0 4px rgba(0,0,0,0.2); }
.terminal-title {
color: #999;
font-size: 13px;
margin: 0 auto;
font-weight: 500;
transform: translateX(-20px); /* Ajuste visual por los puntos */
}
.terminal-cuerpo { padding: 20px; }
.terminal-salida {
height: 250px;
overflow-y: auto;
color: #4af626;
margin-bottom: 15px;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.5;
text-shadow: 0 0 2px rgba(74, 246, 38, 0.4);
}
.terminal-salida::-webkit-scrollbar { width: 8px; }
.terminal-salida::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; }
.terminal-input-container {
display: flex;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 10px 15px;
border-radius: 8px;
border: 1px solid #333;
}
.terminal-input {
flex-grow: 1;
background-color: transparent;
color: #fff;
border: none;
font-family: inherit;
font-size: 14px;
outline: none;
}
.terminal-btn {
background: #333;
color: white;
padding: 8px 16px;
border-radius: 6px;
box-shadow: none;
}
.terminal-btn:hover { background: #555; transform: none; box-shadow: none; }
</style>
</head>
<body>
<div class="contenedor">
<h1>CENTRO DE MONITOREO DE CALIDAD DEL AIRE</h1>
<p style="color: #86868b; margin-bottom: 30px; font-size: 1.1rem; font-weight: 500;">Adquisición de Datos de Calidad del Aire en Tiempo Real</p>
<!-- FRANJA NOM-172 -->
<div id="franja-ica">Calculando Índice de Calidad del Aire (ICA)...</div>
<!-- TARJETAS DE LECTURA EN TIEMPO REAL -->
<div class="grid-tarjetas">
<div class="tarjeta">
<div class="titulo-tarjeta"><span>☁️</span> Monóxido de Carbono (CO)</div>
<div class="valor-tarjeta" id="valor-co">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>🛡️</span> Ozono (O3)</div>
<div class="valor-tarjeta" id="valor-o3">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>🚗</span> Dióxido de Nitrógeno (NO2)</div>
<div class="valor-tarjeta" id="valor-no2">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>🏭</span> Dióxido de Azufre (SO2)</div>
<div class="valor-tarjeta" id="valor-so2">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>🌡️</span> Temperatura</div>
<div class="valor-tarjeta" id="valor-temp">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>💧</span> Humedad</div>
<div class="valor-tarjeta" id="valor-hum">--</div>
</div>
<div class="tarjeta">
<div class="titulo-tarjeta"><span>🧭</span> Presión Atmosférica</div>
<div class="valor-tarjeta" id="valor-pres">--</div>
</div>
</div>
<!-- GRÁFICA COMPARATIVA ICA -->
<div class="tarjeta-grafica" style="margin-bottom: 50px;">
<h2>Comparativa de Índices de Calidad (ICA)</h2>
<canvas id="graficaComparativaICA" height="80"></canvas>
</div>
<!-- SECCIÓN DE GRÁFICAS INDIVIDUALES -->
<div class="controles-superiores">
<h2 style="margin: 0;">Tendencias Históricas Individuales</h2>
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<select id="selector-rango" onchange="gestionarSelector()">
<option value="1_mes">Último Mes</option>
<option value="2_meses">Últimos 2 Meses</option>
<option value="1_anio">Último Año</option>
<option value="personalizado">Personalizado...</option>
</select>
<div id="rango-personalizado" style="display: none; gap: 10px; align-items: center;">
<input type="date" id="fecha-inicio">
<span style="font-weight: 600; color: #86868b;">a</span>
<input type="date" id="fecha-fin">
<button class="btn-buscar" onclick="actualizarGrafica()">🔍 Buscar</button>
</div>
<button class="btn-csv" onclick="exportarCSV()">📄 Exportar CSV</button>
<button class="btn-excel" onclick="exportarExcel()">📊 Exportar Excel</button>
</div>
</div>
<div class="grid-graficas">
<div class="tarjeta-grafica">
<h2>Gráfica de Monóxido de Carbono (CO)</h2>
<canvas id="graficaCO"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Ozono (O3)</h2>
<canvas id="graficaO3"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Dióxido de Nitrógeno (NO2)</h2>
<canvas id="graficaNO2"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Dióxido de Azufre (SO2)</h2>
<canvas id="graficaSO2"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Temperatura</h2>
<canvas id="graficaTemp"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Humedad</h2>
<canvas id="graficaHum"></canvas>
</div>
<div class="tarjeta-grafica">
<h2>Gráfica de Presión Atmosférica</h2>
<canvas id="graficaPres"></canvas>
</div>
</div>
<!-- MINITERMINAL REMOTA ESTILO MACOS -->
<div class="tarjeta-terminal">
<div class="terminal-header">
<div class="terminal-dots">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
</div>
<div class="terminal-title">pi@microestacion:~ (Bash)</div>
</div>
<div class="terminal-cuerpo">
<div class="terminal-salida" id="terminal-salida">Bienvenido a la Raspberry Pi. Sistema listo.
Para consultar parámetros manuales utilice los comandos locales (ej. aql clima, aql CO, aql values)...
</div>
<div class="terminal-input-container">
<span style="color: #4af626; font-weight: bold;">$</span>
<input type="text" id="terminal-input" class="terminal-input" placeholder="Escribe un comando..." onkeypress="manejarEnterTerminal(event)">
<button class="terminal-btn" onclick="ejecutarComando()">Enviar</button>
</div>
</div>
</div>
</div>
<script>
let datosGlobalesParaExportar = [];
// --- 1. LÓGICA NOM-172 ---
function calcularICA(cp, gas) {
const breakpoints = {
"O3": [[0.0, 0.051, 0, 50], [0.052, 0.070, 51, 100], [0.071, 0.092, 101, 150], [0.093, 0.114, 151, 200], [0.115, 10.0, 201, 500]],
"CO": [[0.0, 8.74, 0, 50], [8.75, 11.00, 51, 100], [11.01, 13.30, 101, 150], [13.31, 15.50, 151, 200], [15.51, 100.0, 201, 500]],
"SO2": [[0.0, 0.008, 0, 50], [0.009, 0.110, 51, 100], [0.111, 0.165, 101, 150], [0.166, 0.220, 151, 200], [0.221, 10.0, 201, 500]],
"NO2": [[0.0, 0.107, 0, 50], [0.108, 0.210, 51, 100], [0.211, 0.230, 101, 150], [0.231, 0.250, 151, 200], [0.251, 10.0, 201, 500]]
};
if (!breakpoints[gas]) return 0;
for (let bp of breakpoints[gas]) {
if (cp >= bp[0] && cp <= bp[1]) {
return Math.round(((bp[3] - bp[2]) / (bp[1] - bp[0])) * (cp - bp[0]) + bp[2]);
}
}
return 500;
}
// Modificado para usar gradientes modernos en la franja
function obtenerColorBanda(ica) {
if (ica <= 50) return { texto: "Buena", color: "linear-gradient(135deg, #34c759, #28a745)" };
if (ica <= 100) return { texto: "Aceptable", color: "linear-gradient(135deg, #ffcc00, #e5b700)" };
if (ica <= 150) return { texto: "Mala", color: "linear-gradient(135deg, #ff9500, #e58600)" };
if (ica <= 200) return { texto: "Muy Mala", color: "linear-gradient(135deg, #ff3b30, #d93128)" };
return { texto: "Extremadamente Mala", color: "linear-gradient(135deg, #af52de, #9948c2)" };
}
let graficaComparativa;
// --- 2. TIEMPO REAL ---
async function obtenerDatosActuales() {
try {
const respuesta = await fetch('/api/actual');
const datos = await respuesta.json();
if (datos.estado === "desconectado" || datos.estado === "error") {
const mensajeError = `<span class="error-desconectado">⚠️ Desconectado</span>`;
document.querySelectorAll('.valor-tarjeta').forEach(el => el.innerHTML = mensajeError);
document.getElementById('franja-ica').innerText = "Error: Sensores Desconectados";
document.getElementById('franja-ica').style.background = "linear-gradient(135deg, #86868b, #6e6e73)";
return;
}
document.getElementById('valor-co').innerHTML = `${datos.CO} <span class="unidad">ppm</span>`;
document.getElementById('valor-o3').innerHTML = `${datos.O3} <span class="unidad">ppm</span>`;
document.getElementById('valor-no2').innerHTML = `${datos.NO2} <span class="unidad">ppm</span>`;
document.getElementById('valor-so2').innerHTML = `${datos.SO2} <span class="unidad">ppm</span>`;
document.getElementById('valor-temp').innerHTML = `${datos.TEMP} <span class="unidad">°C</span>`;
document.getElementById('valor-hum').innerHTML = `${datos.HUM} <span class="unidad">%</span>`;
document.getElementById('valor-pres').innerHTML = `${datos.PRES} <span class="unidad">hPa</span>`;
let icaCO = calcularICA(parseFloat(datos.CO), "CO");
let icaO3 = calcularICA(parseFloat(datos.O3), "O3");
let icaNO2 = calcularICA(parseFloat(datos.NO2), "NO2");
let icaSO2 = calcularICA(parseFloat(datos.SO2), "SO2");
let icaMaximo = Math.max(icaCO, icaO3, icaNO2, icaSO2);
let banda = obtenerColorBanda(icaMaximo);
let franja = document.getElementById('franja-ica');
franja.style.background = banda.color;
franja.innerText = `Calidad del Aire: ${banda.texto} (ICA: ${icaMaximo})`;
if(graficaComparativa) graficaComparativa.destroy();
const ctxComp = document.getElementById('graficaComparativaICA').getContext('2d');
graficaComparativa = new Chart(ctxComp, {
type: 'bar',
data: {
labels: ['Monóxido de Carbono', 'Ozono', 'Dióxido de Nitrógeno', 'Dióxido de Azufre'],
datasets: [{
label: 'Índice ICA Individual',
data: [icaCO, icaO3, icaNO2, icaSO2],
backgroundColor: ['#0071e3', '#34c759', '#ff9500', '#ff3b30'],
borderRadius: 12, // Bordes más redondeados
borderSkipped: false
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, suggestedMax: 150, grid: { color: '#f0f0f5' } },
x: { grid: { display: false } }
}
}
});
} catch (error) { console.error("Error de conexión:", error); }
}
setInterval(obtenerDatosActuales, 10000);
obtenerDatosActuales();
// --- 3. GRÁFICAS HISTÓRICAS INDIVIDUALES ---
let historicas = {};
function renderizarGraficaIndividual(idCanvas, etiquetas, datos, label, colorHex) {
if (historicas[idCanvas]) { historicas[idCanvas].destroy(); }
const ctx = document.getElementById(idCanvas).getContext('2d');
// Crear gradiente para el relleno debajo de la línea
let gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, colorHex + '66'); // 40% opacidad
gradient.addColorStop(1, colorHex + '00'); // Transparente
historicas[idCanvas] = new Chart(ctx, {
type: 'line',
data: {
labels: etiquetas.length > 0 ? etiquetas : ['Sin datos'],
datasets: [{
label: label,
data: datos.length > 0 ? datos : [0],
borderColor: colorHex,
backgroundColor: gradient, // Relleno degradado moderno
borderWidth: 3, // Línea un poco más gruesa
tension: 0.4,
fill: true,
pointBackgroundColor: '#ffffff',
pointBorderColor: colorHex,
pointBorderWidth: 2
}]
},
options: {
responsive: true,
aspectRatio: 2.5,
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
titleColor: '#1d1d1f',
bodyColor: '#1d1d1f',
borderColor: 'rgba(0,0,0,0.1)',
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true
}
},
scales: {
x: {
display: true,
title: { display: true, text: 'Fecha y Hora', color: '#86868b', font: { weight: '600', size: 13 } },
grid: { display: false }, // Limpieza visual en eje X
border: { display: true, color: '#e5e5ea', width: 2 },
ticks: { display: true, color: '#86868b', maxTicksLimit: 10, maxRotation: 45, minRotation: 45, font: {size: 11} }
},
y: {
display: true,
title: { display: true, text: label, color: '#86868b', font: { weight: '600', size: 13 } },
grid: { display: true, color: '#f0f0f5', drawOnChartArea: true, drawTicks: false },
border: { display: false }, // Ocultar borde Y para un look más abierto
ticks: { display: true, color: '#86868b', font: {size: 11}, padding: 10 },
beginAtZero: true
}
},
elements: { point: { radius: 0, hitRadius: 15, hoverRadius: 6 } }, // Puntos invisibles hasta hacer hover
interaction: { mode: 'index', intersect: false }
}
});
}
function gestionarSelector() {
const rango = document.getElementById('selector-rango').value;
const divPersonalizado = document.getElementById('rango-personalizado');
if (rango === 'personalizado') {
divPersonalizado.style.display = 'flex';
} else {
divPersonalizado.style.display = 'none';
actualizarGrafica();
}
}
async function actualizarGrafica() {
const rango = document.getElementById('selector-rango').value;
let urlAPI = `/api/historial?rango=${rango}`;
if (rango === 'personalizado') {
const inicio = document.getElementById('fecha-inicio').value;
const fin = document.getElementById('fecha-fin').value;
if (!inicio || !fin) {
alert("Por favor, selecciona una fecha de inicio y una de fin.");
return;
}
urlAPI += `&inicio=${inicio}&fin=${fin}`;
}
const respuesta = await fetch(urlAPI);
const datosHistorial = await respuesta.json();
datosGlobalesParaExportar = datosHistorial;
if (datosHistorial.length === 0) {
alert("La base de datos no tiene registros para este rango de fechas.");
}
const fechas = datosHistorial.map(f => f.fecha_hora);
renderizarGraficaIndividual('graficaCO', fechas, datosHistorial.map(f => f.co), 'Monóxido de Carbono (ppm)', '#0071e3');
renderizarGraficaIndividual('graficaO3', fechas, datosHistorial.map(f => f.ozono), 'Ozono (ppm)', '#34c759');
renderizarGraficaIndividual('graficaNO2', fechas, datosHistorial.map(f => f.no2), 'Dióxido de Nitrógeno (ppm)', '#ff9500');
renderizarGraficaIndividual('graficaSO2', fechas, datosHistorial.map(f => f.so2), 'Dióxido de Azufre (ppm)', '#ff3b30');
renderizarGraficaIndividual('graficaTemp', fechas, datosHistorial.map(f => f.temperatura), 'Temperatura (°C)', '#ffcc00');
renderizarGraficaIndividual('graficaHum', fechas, datosHistorial.map(f => f.humedad), 'Humedad (%)', '#5ac8fa');
renderizarGraficaIndividual('graficaPres', fechas, datosHistorial.map(f => f.presion), 'Presión Atmosférica (hPa)', '#5856d6');
}
// --- 4. FUNCIÓN PARA EXPORTAR A CSV (EXCEL) ---
function exportarCSV() {
if (datosGlobalesParaExportar.length === 0) {
alert("No hay datos disponibles para exportar en este rango.");
return;
}
let contenidoCSV = "Fecha y Hora,CO (ppm),O3 (ppm),NO2 (ppm),SO2 (ppm),Temperatura (C),Humedad (%),Presion Atmosferica (hPa)\n";
datosGlobalesParaExportar.forEach(fila => {
contenidoCSV += `${fila.fecha_hora},${fila.co},${fila.ozono},${fila.no2},${fila.so2},${fila.temperatura},${fila.humedad},${fila.presion}\n`;
});
const blob = new Blob([contenidoCSV], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const enlaceVirtual = document.createElement("a");
enlaceVirtual.setAttribute("href", url);
enlaceVirtual.setAttribute("download", "Historial_Calidad_Aire.csv");
document.body.appendChild(enlaceVirtual);
enlaceVirtual.click();
document.body.removeChild(enlaceVirtual);
}
// --- 5. FUNCIÓN PARA EXPORTAR A EXCEL (.XLSX) ---
function exportarExcel() {
if (datosGlobalesParaExportar.length === 0) {
alert("No hay datos disponibles para exportar en este rango.");
return;
}
const datosFormateados = datosGlobalesParaExportar.map(fila => ({
"Fecha y Hora": fila.fecha_hora,
"CO (ppm)": fila.co,
"Ozono - O3 (ppm)": fila.ozono,
"Dióxido de Nitrógeno - NO2 (ppm)": fila.no2,
"Dióxido de Azufre - SO2 (ppm)": fila.so2,
"Temperatura (°C)": fila.temperatura,
"Humedad (%)": fila.humedad,
"Presión Atmosférica (hPa)": fila.presion
}));
const hoja = XLSX.utils.json_to_sheet(datosFormateados);
const libro = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(libro, hoja, "Calidad del Aire");
XLSX.writeFile(libro, "Historial_Calidad_Aire.xlsx");
}
// --- 6. LÓGICA DE LA MINITERMINAL ---
function manejarEnterTerminal(event) {
if (event.key === "Enter") {
ejecutarComando();
}
}
async function ejecutarComando() {
const inputElement = document.getElementById('terminal-input');
const salidaElement = document.getElementById('terminal-salida');
const comando = inputElement.value.trim();
if (!comando) return;
salidaElement.innerHTML += `\n<span style="color: #ffffff;">$ ${comando}</span>\n`;
inputElement.value = "";
salidaElement.scrollTop = salidaElement.scrollHeight;
try {
const respuesta = await fetch('/api/ejecutar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comando: comando })
});
const datos = await respuesta.json();
if (datos.error && datos.error !== "") {
salidaElement.innerHTML += `<span style="color: #ff5f56;">${datos.error}</span>`;
} else if (datos.salida && datos.salida !== "") {
salidaElement.innerHTML += `${datos.salida}`;
}
} catch (error) {
salidaElement.innerHTML += `<span style="color: #ff5f56;">Error de comunicación con el servidor.</span>\n`;
}
salidaElement.scrollTop = salidaElement.scrollHeight;
}
actualizarGrafica();
</script>
</body>
</html>