From c9e16ea0b0549a538dc238e3fbeb2a1e1967eb92 Mon Sep 17 00:00:00 2001 From: JoshBaneyCS Date: Wed, 30 Apr 2025 03:59:01 +0000 Subject: [PATCH] Update public/scripts/trends.js --- public/scripts/trends.js | 267 +++++++++++++++++++-------------------- 1 file changed, 127 insertions(+), 140 deletions(-) diff --git a/public/scripts/trends.js b/public/scripts/trends.js index a6e1cc2..1136ba0 100644 --- a/public/scripts/trends.js +++ b/public/scripts/trends.js @@ -1,148 +1,135 @@ // public/scripts/trends.js -// Helper to compute average -function average(arr) { - return arr.reduce((a, b) => a + b, 0) / arr.length; +// ─── Timeframes ────────────────────────────────────────────────────────────── +const tfConfig = [ + { unit:'hours', count:24, label:'Last 24 Hours' }, + { unit:'days', count:7, label:'Last 7 Days' }, + { unit:'weeks', count:4, label:'Last 4 Weeks' }, + { unit:'months', count:12, label:'Last 12 Months' }, + { unit:'years', count:1, label:'All Time' } +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── +function formatEST(epoch) { + return new Date(epoch).toLocaleString('en-US', { + timeZone: 'America/New_York', + month: 'numeric', + day: 'numeric', + year: '2-digit', + hour12: false, + hour: '2-digit', + minute: '2-digit' + }).replace(',', ' @'); } - -// Period definitions -const periodKeys = ['hourly','daily','weekly','monthly','yearly']; -const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly']; -const periodConfig = { - hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') }, - daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k }, - weekly: { keyFn: r => { - const d = new Date(r.timestamp), y = d.getFullYear(); - const w = Math.ceil((((d - new Date(y,0,1))/864e5) + new Date(y,0,1).getDay()+1)/7); - return `${y}-W${w}`; - }, labelFn: k => k }, - monthly: { keyFn: r => r.timestamp.slice(0,7), labelFn: k => k }, - yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k } -}; - -let trendChart, dataTable; - -// Fetch all readings from server -async function fetchReadings() { - const res = await fetch('/api/readings'); - return res.ok ? res.json() : []; -} - -// Render the DataTable under the chart -function renderTable(allReadings) { - const tbody = $('#trendTable tbody').empty(); - allReadings.forEach(r => { - const ts = new Date(r.timestamp) - .toLocaleString('en-US',{timeZone:'America/New_York'}); - const loc = r.stationDockDoor; // dock door or station code - const dir = r.location; // Inbound, Outbound, A-Mod, AFE-1... - tbody.append(` - - ${ts} - ${r.temperature.toFixed(1)} - ${r.humidity.toFixed(1)} - ${r.heatIndex.toFixed(1)} - ${loc} - ${dir} - - `); - }); - - if ($.fn.DataTable.isDataTable('#trendTable')) { - dataTable.clear().rows.add($('#trendTable tbody tr')).draw(); - } else { - dataTable = $('#trendTable').DataTable({ - paging: true, - pageLength: 25, - ordering: true, - order: [[0,'desc']], - autoWidth: false, - scrollX: true - }); +function subtract(date, count, unit) { + const d = new Date(date); + switch(unit){ + case 'hours': d.setHours(d.getHours() - count); break; + case 'days': d.setDate(d.getDate() - count); break; + case 'weeks': d.setDate(d.getDate() - 7*count); break; + case 'months': d.setMonth(d.getMonth() - count); break; + case 'years': d.setFullYear(d.getFullYear() - count); break; } + return d; } -// Build or update the Chart.js line chart -async function drawTrend() { - const all = await fetchReadings(); - const slider = document.getElementById('periodSlider'); - const key = periodKeys[slider.value]; - const cfg = periodConfig[key]; +// ─── State ──────────────────────────────────────────────────────────────────── +let readings = []; +let chart; - // Group readings by time bucket - const groups = {}; - all.forEach(r => { - const k = cfg.keyFn(r); - groups[k] = groups[k] || { temps:[], hums:[], his:[] }; - groups[k].temps.push(r.temperature); - groups[k].hums.push(r.humidity); - groups[k].his.push(r.heatIndex); - }); - - const labels = Object.keys(groups).sort(); - const stats = labels.map(k => { - const g = groups[k]; - return { - temp: { avg: average(g.temps).toFixed(2), min: Math.min(...g.temps), max: Math.max(...g.temps) }, - hum: { avg: average(g.hums).toFixed(2), min: Math.min(...g.hums), max: Math.max(...g.hums) }, - hi: { avg: average(g.his).toFixed(2), min: Math.min(...g.his), max: Math.max(...g.his) } - }; - }); - - // Create datasets from checked toggles - const checked = Array.from(document.querySelectorAll('#metricToggles input:checked')); - const datasets = checked.map(chk => { - const m = chk.dataset.metric, s = chk.dataset.stat; - return { - label: `${m.toUpperCase()} ${s.toUpperCase()}`, - data: stats.map(x => x[m][s]), - fill: false, - tension: 0.1 - }; - }); - - const ctx = document.getElementById('trendChart').getContext('2d'); - if (trendChart) { - trendChart.data.labels = labels.map(cfg.labelFn); - trendChart.data.datasets = datasets; - trendChart.update(); - } else { - trendChart = new Chart(ctx, { - type: 'line', - data: { labels: labels.map(cfg.labelFn), datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { display: true }, - tooltip: { mode:'index', intersect:false }, - zoom: { - pan: { enabled:true, mode:'x', modifierKey:'ctrl' }, - zoom: { wheel:{enabled:true}, pinch:{enabled:true}, mode:'x' } - } - }, - scales: { - x: { display:true }, - y: { display:true } - } - } - }); - } - - // Always update the table below - renderTable(all); -} - -// Wire up controls and initial draw -document.addEventListener('DOMContentLoaded', () => { - drawTrend(); - - document.getElementById('periodSlider') - .addEventListener('input', e => { - document.getElementById('periodLabel').textContent = periodLabels[e.target.value]; - drawTrend(); - }); - - document.querySelectorAll('#metricToggles input') - .forEach(chk => chk.addEventListener('change', drawTrend)); +// ─── On Load ───────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', async() => { + const data = await fetch('/api/readings').then(r=>r.json()); + readings = data.map(r=>({ + ...r, + ts: formatEST(r.epoch_ms), + date: new Date(r.epoch_ms) + })); + setupUI(); initChart(); updateView(); }); + +// ─── UI Wiring ──────────────────────────────────────────────────────────────── +function setupUI(){ + const slider = document.getElementById('timeframe-slider'); + const label = document.getElementById('timeframe-label'); + slider.addEventListener('input',()=>{ + label.textContent = tfConfig[slider.value].label; + updateView(); + }); + label.textContent = tfConfig[slider.value].label; + + document.getElementsByName('metric').forEach(cb=>cb.addEventListener('change',updateView)); + + document.querySelectorAll('#trends-table thead th.sortable').forEach(th=>{ + th.addEventListener('click', ()=>{ + const idx = th.cellIndex; + const asc = !th.classList.contains('asc'); + const tbody = document.getElementById('trends-table-body'); + const rows = Array.from(tbody.rows); + rows.sort((a,b)=>{ + return asc + ? a.cells[idx].textContent.localeCompare(b.cells[idx].textContent,undefined,{numeric:true}) + : b.cells[idx].textContent.localeCompare(a.cells[idx].textContent,undefined,{numeric:true}); + }); + th.classList.toggle('asc',asc); + rows.forEach(r=>tbody.appendChild(r)); + }); + }); +} + +// ─── Chart.js Setup ────────────────────────────────────────────────────────── +function initChart(){ + const ctx = document.getElementById('trend-chart').getContext('2d'); + chart = new Chart(ctx,{ + type:'line', + data:{labels:[],datasets:[]}, + options:{ + scales:{ + x:{ title:{display:true,text:'Time (EST)'} }, + y:{ title:{display:true,text:'Value'} } + }, + interaction:{mode:'index',intersect:false}, + plugins:{legend:{position:'top'}}, + maintainAspectRatio:false + } + }); +} + +// ─── Render ─────────────────────────────────────────────────────────────────── +function updateView(){ + const idx = +document.getElementById('timeframe-slider').value; + const {unit,count} = tfConfig[idx]; + const cutoff = subtract(Date.now(),count,unit); + + const filtered = readings.filter(r=>r.date>=cutoff); + const selected = Array.from(document.getElementsByName('metric')) + .filter(cb=>cb.checked).map(cb=>cb.value); + + const labels = filtered.map(r=>r.ts); + const datasets = []; + if (selected.includes('temperature')) + datasets.push({ label:'Temperature (°F)', data:filtered.map(r=>r.temperature), tension:0.3 }); + if (selected.includes('humidity')) + datasets.push({ label:'Humidity (%)', data:filtered.map(r=>r.humidity), tension:0.3 }); + if (selected.includes('heatIndex')) + datasets.push({ label:'Heat Index (°F)', data:filtered.map(r=>r.heatIndex), tension:0.3 }); + + chart.data.labels = labels; + chart.data.datasets = datasets; + chart.update(); + + const tbody = document.getElementById('trends-table-body'); + tbody.innerHTML = ''; + filtered.forEach(r=>{ + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${r.ts} + ${r.temperature.toFixed(1)} + ${r.humidity.toFixed(1)} + ${r.heatIndex.toFixed(2)} + ${r.stationDockDoor} + ${r.location} + `; + tbody.appendChild(tr); + }); +}