171 lines
10 KiB
JavaScript
171 lines
10 KiB
JavaScript
/* Estadísticas del registro diario, compartidas por NW.js y la interfaz web. */
|
|
const EstadisticasUI = (() => {
|
|
const variables = [
|
|
['temperatura_externa', 'Temperatura exterior', '°C', 'temperature'],
|
|
['temperatura_interna', 'Temperatura interior', '°C', 'temperature'],
|
|
['humedad_externa', 'Humedad exterior', '%'],
|
|
['humedad_interna', 'Humedad interior', '%'],
|
|
['punto_de_rocio', 'Punto de rocío', '°C', 'temperature'],
|
|
['viento_velocidad', 'Velocidad del viento', 'km/h', 'speed'],
|
|
['viento_rafagas', 'Ráfagas', 'km/h', 'speed'],
|
|
['presion_relativa', 'Presión relativa', 'hPa'],
|
|
['lluvia_acu_hora', 'Lluvia acumulada en una hora', 'mm'],
|
|
['lluvia_acu_diaria', 'Lluvia acumulada diaria', 'mm'],
|
|
['luxer_uv', 'Índice UV', ''],
|
|
['luxer_intencidad', 'Iluminación', 'lux']
|
|
];
|
|
let generation = 0;
|
|
function numeric(value) {
|
|
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
|
if (typeof value === 'string' && !value.trim()) return null;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
function timestamp(record) {
|
|
const iso = Date.parse(record.fechaISO);
|
|
if (Number.isFinite(iso)) return iso;
|
|
const parts = String(record.fecha || '').match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
return parts ? new Date(+parts[3], +parts[2] - 1, +parts[1], +parts[4], +parts[5], +(parts[6] || 0)).getTime() : NaN;
|
|
}
|
|
function analyze(input, system = 'SI') {
|
|
const records = typeof input === 'string' ? JSON.parse(input) : input;
|
|
if (!Array.isArray(records)) throw new Error('El registro diario no contiene una lista de mediciones.');
|
|
const rows = records.filter(r => r && typeof r === 'object' && !Array.isArray(r))
|
|
.map(record => ({record, time: timestamp(record)}))
|
|
.filter(row => Number.isFinite(row.time)).sort((a, b) => a.time - b.time);
|
|
const series = variables.map(([key, label, unit, kind]) => {
|
|
const imperial = system !== 'SI';
|
|
const points = rows.map(({record, time}) => {
|
|
let value = numeric(record[key]);
|
|
if (value !== null && imperial) {
|
|
if (kind === 'temperature') value = value * 9 / 5 + 32;
|
|
if (kind === 'speed') value *= 0.621371192;
|
|
}
|
|
return {time, value, label: String(record.fecha || record.fechaISO)};
|
|
});
|
|
const values = points.map(p => p.value).filter(v => v !== null);
|
|
// No sumar acumulados de lluvia ni convertir ausencias en ceros.
|
|
const stats = values.reduce((acc, v) => ({min: Math.min(acc.min, v), max: Math.max(acc.max, v), sum: acc.sum + v}), {min: Infinity, max: -Infinity, sum: 0});
|
|
return {key, label, unit: imperial && kind === 'temperature' ? '°F' : imperial && kind === 'speed' ? 'mph' : unit,
|
|
points, count: values.length, min: values.length ? stats.min : null,
|
|
max: values.length ? stats.max : null, mean: values.length ? stats.sum / values.length : null,
|
|
last: points.length ? points[points.length - 1].value : null};
|
|
});
|
|
return {total: records.length, dated: rows.length, discarded: records.length - rows.length,
|
|
changed: rows.filter(r => r.record.cambio === true).length, series,
|
|
first: rows[0]?.record.fecha || rows[0]?.record.fechaISO,
|
|
last: rows.at(-1)?.record.fecha || rows.at(-1)?.record.fechaISO};
|
|
}
|
|
const format = value => value === null ? '—' : value.toLocaleString('es-AR', {maximumFractionDigits: 2});
|
|
function element(tag, text, className) {
|
|
const node = document.createElement(tag);
|
|
if (text !== undefined) node.textContent = text;
|
|
if (className) node.className = className;
|
|
return node;
|
|
}
|
|
function draw(target, series) {
|
|
target.replaceChildren();
|
|
if (!series.count) { target.append(element('p', 'No hay mediciones válidas para esta variable.')); return; }
|
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
function svgNode(tag, attrs, text) {
|
|
const node = document.createElementNS(svgNS, tag);
|
|
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
const svg = svgNode('svg', {viewBox: '0 0 900 320', role: 'img', 'aria-label': series.label + ' (' + series.unit + ')', style: 'width:100%;max-height:420px;min-height:220px'});
|
|
svg.append(svgNode('title', {}, series.label + ' · ' + series.unit));
|
|
const first = series.points[0].time, last = series.points.at(-1).time;
|
|
const span = series.max - series.min || Math.max(Math.abs(series.max) * 0.1, 1);
|
|
const low = series.min - span * 0.1, high = series.max + span * 0.1;
|
|
const x = t => first === last ? 470 : 80 + (t - first) / (last - first) * 780;
|
|
const y = v => 265 - (v - low) / (high - low) * 240;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const value = low + (high - low) * i / 4;
|
|
svg.append(svgNode('line', {x1:80, x2:860, y1:y(value), y2:y(value), stroke:'currentColor', opacity:0.2}));
|
|
svg.append(svgNode('text', {x:72, y:y(value) + 4, 'text-anchor':'end', fill:'currentColor', 'font-size':13}, format(value)));
|
|
}
|
|
let path = '', gap = true;
|
|
for (const point of series.points) {
|
|
if (point.value === null) { gap = true; continue; }
|
|
path += `${gap ? 'M' : 'L'}${x(point.time).toFixed(2)},${y(point.value).toFixed(2)} `;
|
|
gap = false;
|
|
}
|
|
svg.append(svgNode('path', {d:path, fill:'none', stroke:'#64b5ff', 'stroke-width':2, 'vector-effect':'non-scaling-stroke'}));
|
|
// Limitar marcadores DOM para históricos grandes, manteniendo la curva completa.
|
|
const valid = series.points.filter(p => p.value !== null);
|
|
const step = Math.max(1, Math.ceil(valid.length / 200));
|
|
valid.forEach((point, i) => {
|
|
if (i % step && i !== valid.length - 1) return;
|
|
const dot = svgNode('circle', {cx:x(point.time), cy:y(point.value), r:3, fill:'#64b5ff'});
|
|
dot.append(svgNode('title', {}, `${point.label}: ${format(point.value)} ${series.unit}`));
|
|
svg.append(dot);
|
|
});
|
|
svg.append(svgNode('text', {x:80, y:300, fill:'currentColor', 'font-size':12}, series.points[0].label));
|
|
svg.append(svgNode('text', {x:860, y:300, 'text-anchor':'end', fill:'currentColor', 'font-size':12}, series.points.at(-1).label));
|
|
target.append(svg);
|
|
}
|
|
function render(target, result) {
|
|
target.replaceChildren();
|
|
if (!result.dated) {
|
|
target.append(element('p', result.total ? 'No hay registros con fecha válida para procesar.' : 'Todavía no hay registros diarios. Iniciá la recolección y actualizá esta pantalla.', 'alert alert-info'));
|
|
return;
|
|
}
|
|
target.append(element('p', `${result.dated} registros · ${result.changed} con cambios · ${result.first} — ${result.last}`));
|
|
if (result.discarded) target.append(element('p', `Se omitieron ${result.discarded} registros sin fecha válida.`, 'alert alert-warning'));
|
|
const tableBox = element('div', undefined, 'table-responsive');
|
|
const table = element('table', undefined, 'table table-bordered table-sm');
|
|
table.append(element('caption', 'Resumen de las muestras disponibles. Los acumulados de lluvia no se suman. — indica un dato ausente.'));
|
|
const header = element('tr');
|
|
for (const label of ['Variable', 'Unidad', 'Muestras', 'Mínimo', 'Promedio', 'Máximo', 'Último']) { const th = element('th', label); th.scope = 'col'; header.append(th); }
|
|
const head = element('thead'); head.append(header); table.append(head);
|
|
const body = element('tbody');
|
|
for (const series of result.series) {
|
|
const row = element('tr');
|
|
for (const value of [series.label, series.unit, series.count, format(series.min), format(series.mean), format(series.max), format(series.last)]) row.append(element('td', value));
|
|
body.append(row);
|
|
}
|
|
table.append(body); tableBox.append(table); target.append(tableBox);
|
|
const label = element('label', 'Variable del gráfico', 'form-label'); label.htmlFor = 'estadisticas-variable';
|
|
const select = element('select', undefined, 'form-select mb-3'); select.id = label.htmlFor;
|
|
for (const series of result.series) {
|
|
const option = element('option', `${series.label} (${series.unit || 'índice'})`); option.value = series.key; select.append(option);
|
|
}
|
|
select.value = (result.series.find(s => s.count) || result.series[0]).key;
|
|
const graph = element('div'); graph.dataset.estadisticasGraph = '';
|
|
select.onchange = () => draw(graph, result.series.find(s => s.key === select.value));
|
|
target.append(label, select, graph); select.onchange();
|
|
}
|
|
async function mount(root, {mode, system = 'SI', load}) {
|
|
if (!root) return;
|
|
const id = ++generation;
|
|
const target = root.querySelector('[data-estadisticas-content]');
|
|
const button = root.querySelector('[data-estadisticas-refresh]');
|
|
if (!target || !button) return;
|
|
button.onclick = () => mount(root, {mode, system, load});
|
|
if (!['Recolector', 'Recolector_USB', 'Recolector_IP'].includes(mode)) {
|
|
button.disabled = true;
|
|
const notice = element('p', 'Las estadísticas del registro diario están disponibles en modo recolector.', 'alert alert-info');
|
|
notice.style.setProperty('--bs-alert-bg', '#1a606f');
|
|
notice.style.setProperty('--bs-alert-color', '#fff');
|
|
target.replaceChildren(notice);
|
|
return;
|
|
}
|
|
button.disabled = true;
|
|
target.setAttribute('aria-busy', 'true');
|
|
target.replaceChildren(element('p', 'Cargando estadísticas…', 'alert alert-info'));
|
|
try {
|
|
const result = analyze(await load(), system);
|
|
if (id === generation && root.isConnected) render(target, result);
|
|
} catch (error) {
|
|
if (id === generation && root.isConnected) target.replaceChildren(element('p', 'No se pudieron cargar las estadísticas. Revisá el registro diario y volvé a intentar.', 'alert alert-danger'));
|
|
console.warn('Estadísticas:', error.message);
|
|
} finally {
|
|
if (id === generation) { button.disabled = false; target.setAttribute('aria-busy', 'false'); }
|
|
}
|
|
}
|
|
function dispose() { generation++; }
|
|
return {analyze, mount, dispose};
|
|
})();
|
|
if (typeof module === 'object' && module.exports) module.exports = EstadisticasUI;
|