From 9e25a38a29cdc9036415324712188e453444050f Mon Sep 17 00:00:00 2001 From: JoshBaneyCS Date: Tue, 29 Apr 2025 01:24:24 +0000 Subject: [PATCH] Upload files to "public/scripts" --- public/scripts/heatmap.js | 199 +++++++++++++++++++++++++++++++++++ public/scripts/input-area.js | 40 +++++++ public/scripts/input.js | 29 +++++ public/scripts/trends.js | 161 ++++++++++++++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 public/scripts/heatmap.js create mode 100644 public/scripts/input-area.js create mode 100644 public/scripts/input.js create mode 100644 public/scripts/trends.js diff --git a/public/scripts/heatmap.js b/public/scripts/heatmap.js new file mode 100644 index 0000000..f0b3d27 --- /dev/null +++ b/public/scripts/heatmap.js @@ -0,0 +1,199 @@ +// public/scripts/heatmap.js + +// ==== Configuration ==== +const dockDoors = [ + ...Array.from({length: 138 - 124 + 1}, (_, i) => 124 + i), + ...Array.from({length: 201 - 142 + 1}, (_, i) => 142 + i), + ...Array.from({length: 209 - 202 + 1}, (_, i) => 202 + i), +]; + +// Map region names → element IDs +const regionEls = { + 'A-Mod': document.getElementById('region-amod'), + 'Outbound Pre-Slam':document.getElementById('region-preslam'), + 'B-Mod': document.getElementById('region-bmod'), +}; + +// ==== State Stores ==== +const doorData = {}; // dockDoor → array of readings +const regionData = {}; // regionName → array of readings + +// Initialize data arrays +dockDoors.forEach(d => doorData[d] = []); +Object.keys(regionEls).forEach(r => regionData[r] = []); + +// ==== Color Helper ==== +function getColorFromHI(H) { + const pct = Math.min(Math.max((H - 70) / 30, 0), 1); + const r = 255; + const g = Math.round(255 * (1 - pct)); + return `rgba(${r},${g},0,0.7)`; +} + +// ==== Stats Helper ==== +function computeStats(arr) { + if (!arr.length) return null; + let sum = 0, max = arr[0], min = arr[0]; + arr.forEach(r => { + sum += r.heatIndex; + if (r.heatIndex > max.heatIndex) max = r; + if (r.heatIndex < min.heatIndex) min = r; + }); + const avg = (sum / arr.length).toFixed(2); + const latest = arr[arr.length - 1]; + return { latest, max, min, avg }; +} + +// ==== Grid Creation ==== +function createGrid() { + const row = document.getElementById('dock-row'); + dockDoors.forEach(d => { + const sq = document.createElement('div'); + sq.className = 'dock-square'; + sq.dataset.door = d; + sq.textContent = d; + row.appendChild(sq); + }); +} + +// ==== Initial Load ==== +async function loadInitial() { + const all = await fetch('/api/readings').then(r=>r.json()); + all.forEach(r => { + if (r.dockDoor != null) { + doorData[r.dockDoor].push(r); + } else if (r.region) { + regionData[r.region].push(r); + } + }); + // Color each dock + Object.entries(doorData).forEach(([d, arr]) => { + const stats = computeStats(arr); + if (stats) { + const el = document.querySelector(`.dock-square[data-door="${d}"]`); + el.style.background = getColorFromHI(stats.latest.heatIndex); + } + }); + // Color each region + Object.entries(regionEls).forEach(([r, el]) => { + const stats = computeStats(regionData[r]); + if (stats) el.style.background = getColorFromHI(stats.latest.heatIndex); + }); +} + +// ==== SSE Subscriptions ==== +function subscribeRealtime() { + const es = new EventSource('/api/stream'); + + es.addEventListener('new-reading', e => { + const r = JSON.parse(e.data); + if (doorData[r.dockDoor]) { + doorData[r.dockDoor].push(r); + const el = document.querySelector(`.dock-square[data-door="${r.dockDoor}"]`); + el.style.background = getColorFromHI(r.heatIndex); + } + }); + + es.addEventListener('new-area-reading', e => { + const r = JSON.parse(e.data); + if (regionData[r.region]) { + regionData[r.region].push(r); + const el = regionEls[r.region]; + el.style.background = getColorFromHI(r.heatIndex); + } + }); +} + +// ==== Tooltip Setup ==== +function setupTooltips() { + const tooltip = document.getElementById('tooltip'); + + // Common hover handlers + function showTip(e, infoHtml) { + tooltip.innerHTML = infoHtml; + tooltip.style.display = 'block'; + } + function moveTip(e) { + tooltip.style.top = `${e.clientY + 10}px`; + tooltip.style.left = `${e.clientX + 10}px`; + } + function hideTip() { + tooltip.style.display = 'none'; + } + + // Dock squares + document.querySelectorAll('.dock-square').forEach(sq => { + sq.addEventListener('mouseenter', e => { + const d = Number(sq.dataset.door); + const stats = computeStats(doorData[d]); + if (!stats) return; + const lt = new Date(stats.latest.timestamp).toLocaleString(); + const ht = new Date(stats.max.timestamp).toLocaleString(); + const lt2= new Date(stats.min.timestamp).toLocaleString(); + const html = ` + Door ${d}
+ Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%
+ Time: ${lt}
+ Max: HI ${stats.max.heatIndex} at ${ht}
+ Min: HI ${stats.min.heatIndex} at ${lt2}
+ Avg HI: ${stats.avg} + `; + showTip(e, html); + }); + sq.addEventListener('mousemove', moveTip); + sq.addEventListener('mouseleave', hideTip); + }); + + // Regions + Object.entries(regionEls).forEach(([r, el]) => { + el.addEventListener('mouseenter', e => { + const stats = computeStats(regionData[r]); + if (!stats) return; + const lr = stats.latest; + const html = ` + ${r}
+ Latest Station: ${lr.stationCode}
+ HI ${lr.heatIndex}, T ${lr.temperature}°F, H ${lr.humidity}%
+ Time: ${new Date(lr.timestamp).toLocaleString()}
+ Max HI: ${stats.max.heatIndex}, Min HI: ${stats.min.heatIndex}
+ Avg HI: ${stats.avg} + `; + showTip(e, html); + }); + el.addEventListener('mousemove', moveTip); + el.addEventListener('mouseleave', hideTip); + }); +} + +// ==== Zoom & Pan via CSS Vars ==== +function setupZoom() { + const root = document.documentElement.style; + let scale = 1; + const init = { + size: 60, + gap: 8, + vgap: 24, + wh: 200 + }; + const wrapper = document.querySelector('.heatmap-wrapper'); + wrapper.addEventListener('wheel', e => { + if (e.ctrlKey) { + e.preventDefault(); + scale += e.deltaY * -0.001; + scale = Math.min(Math.max(scale, 0.5), 3); + root.setProperty('--square-size', `${init.size * scale}px`); + root.setProperty('--gap', `${init.gap * scale}px`); + root.setProperty('--v-gap', `${init.vgap * scale}px`); + root.setProperty('--warehouse-height',`${init.wh * scale}px`); + } + }); +} + +// ==== Init ==== +document.addEventListener('DOMContentLoaded', () => { + createGrid(); + loadInitial(); + subscribeRealtime(); + setupTooltips(); + setupZoom(); +}); diff --git a/public/scripts/input-area.js b/public/scripts/input-area.js new file mode 100644 index 0000000..8c1d9e9 --- /dev/null +++ b/public/scripts/input-area.js @@ -0,0 +1,40 @@ +// public/scripts/input-area.js + +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('area-form'); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + // Gather form values + const area = document.getElementById('area').value; + const stationCode = document.getElementById('stationCode').value; + const temperature = parseFloat(document.getElementById('temperature').value); + const humidity = parseFloat(document.getElementById('humidity').value); + + // Build payload + const payload = { area, stationCode, temperature, humidity }; + + try { + const res = await fetch('/api/area-readings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const { error } = await res.json(); + alert(`Error: ${error}`); + return; + } + + // Success: reset form and optionally notify user + form.reset(); + alert('Area reading submitted successfully!'); + } catch (err) { + console.error('Network error submitting area reading:', err); + alert('Network error – please try again.'); + } + }); + }); + \ No newline at end of file diff --git a/public/scripts/input.js b/public/scripts/input.js new file mode 100644 index 0000000..7936441 --- /dev/null +++ b/public/scripts/input.js @@ -0,0 +1,29 @@ +const form = document.getElementById('reading-form'); +const inDoor = document.getElementById('inboundDoor'); +const outDoor = document.getElementById('outboundDoor'); +const inTemp = document.getElementById('inboundTemp'); +const outTemp = document.getElementById('outboundTemp'); +const inHum = document.getElementById('inboundHum'); +const outHum = document.getElementById('outboundHum'); + +// Auto-set direction fields (readonly) if you want display +// omitted here since direction hidden in dual-input + +form.addEventListener('submit', e => { + e.preventDefault(); + const payload = { + inbound: { + dockDoor: +inDoor.value, + temperature: +inTemp.value, + humidity: +inHum.value + }, + outbound: { + dockDoor: +outDoor.value, + temperature: +outTemp.value, + humidity: +outHum.value + } + }; + fetch('/api/readings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) + }).then(res => res.json()).then(() => form.reset()); +}); \ No newline at end of file diff --git a/public/scripts/trends.js b/public/scripts/trends.js new file mode 100644 index 0000000..6dca130 --- /dev/null +++ b/public/scripts/trends.js @@ -0,0 +1,161 @@ +// average helper +function average(arr) { + return arr.reduce((a, b) => a + b, 0) / arr.length; +} + +// interval mappings +const periodKeys = ['hourly','daily','weekly','monthly','yearly']; +const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly']; + +// bucket definitions +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 } +}; + +// global Chart.js instance +let trendChart, dataTable; + +// fetch readings from server +async function fetchReadings() { + const res = await fetch('/api/readings'); + return res.ok ? res.json() : []; +} + +// determine direction based on dock door # +function getDirection(dock) { + return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) ) + ? 'Inbound' + : 'Outbound'; +} + +// render DataTable +function renderTable(allReadings) { + const tbody = $('#trendTable tbody').empty(); + allReadings.forEach(r => { + const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'Europe/London' }); + const dir = getDirection(r.dockDoor); + tbody.append(` + + ${ts} + ${r.temperature.toFixed(1)} + ${r.humidity.toFixed(1)} + ${r.heatIndex.toFixed(1)} + ${r.dockDoor} + ${dir} + + `); + }); + + // initialize or redraw DataTable + 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 + }); + } +} + +// draw or update chart +async function drawTrend() { + const all = await fetchReadings(); + const slider = document.getElementById('periodSlider'); + const periodKey = periodKeys[slider.value]; + const cfg = periodConfig[periodKey]; + + // group & stats + const groups = {}; + all.forEach(r => { + const key = cfg.keyFn(r); + groups[key] = groups[key] || { temps:[], hums:[], his:[] }; + groups[key].temps.push(r.temperature); + groups[key].hums.push(r.humidity); + groups[key].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) } + }; + }); + + // selected toggles + const checks = Array.from(document.querySelectorAll('#metricToggles input:checked')); + const datasets = checks.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 table below + renderTable(all); +} + +// wire up controls +document.addEventListener('DOMContentLoaded', () => { + // initial draw + drawTrend(); + + // slider + document.getElementById('periodSlider') + .addEventListener('input', e => { + document.getElementById('periodLabel').textContent = periodLabels[e.target.value]; + drawTrend(); + }); + + // toggles + document.querySelectorAll('#metricToggles input') + .forEach(chk => chk.addEventListener('change', drawTrend)); +}); \ No newline at end of file