// 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(); });