// 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), ]; const regionEls = { 'A-Mod': document.getElementById('region-amod'), 'Outbound Pre-Slam': document.getElementById('region-preslam'), 'B-Mod': document.getElementById('region-bmod'), }; const doorData = {}; // dockDoor → [readings] const regionData = {}; // regionName → [readings] dockDoors.forEach(d => doorData[d] = []); Object.keys(regionEls).forEach(r => regionData[r] = []); // ==== HELPERS ==== function getColorFromHI(H) { const pct = Math.min(Math.max((H - 70) / 30, 0), 1); const r = 255, g = Math.round(255 * (1 - pct)); return `rgba(${r},${g},0,0.7)`; } 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; }); return { latest: arr[arr.length - 1], max, min, avg: (sum / arr.length).toFixed(2) }; } function showTooltip(html, x, y) { const tip = document.getElementById('tooltip'); tip.innerHTML = html; tip.style.left = x + 10 + 'px'; tip.style.top = y + 10 + 'px'; tip.style.display = 'block'; } function hideTooltip() { document.getElementById('tooltip').style.display = 'none'; } // ==== LOAD INITIAL DATA ==== async function loadInitial() { const all = await fetch('/api/readings').then(r => r.json()); all.forEach(r => { if (r.location === 'Inbound' || r.location === 'Outbound') { const d = Number(r.stationDockDoor); if (doorData[d]) doorData[d].push(r); } else if (regionData[r.location]) { regionData[r.location].push(r); } }); } // ==== BUILD DOCK-DOOR GRID ==== function buildDockRow() { 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); }); } // ==== DRAW RING FOR A-Mod / B-Mod ==== function drawRing(regionEl, floor, isBmod) { regionEl.querySelectorAll('.station-square').forEach(sq => sq.remove()); const W = regionEl.clientWidth; const H = regionEl.clientHeight; const size = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--square-size')); const gap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--gap')); const innerW = W - 2*gap; const innerH = H - 2*gap; const mk = (x, y, code) => { const sq = document.createElement('div'); sq.className = 'station-square'; sq.textContent = code; sq.style.left = `${x - size/2}px`; sq.style.top = `${y - size/2}px`; regionEl.appendChild(sq); }; // SOUTH (dir=3) → top edge for (let i = 0; i < 26; i++) { const idx = String(i+1).padStart(2,'0'); const code = `${floor}3${idx}`; const x = gap + (i + 0.5)*(innerW/26); mk(x, gap/2, code); } // NORTH (dir=1) → bottom edge for (let i = 0; i < 26; i++) { const idx = String(i+1).padStart(2,'0'); const code = `${floor}1${idx}`; const x = gap + (i + 0.5)*(innerW/26); mk(x, H - gap/2, code); } // WEST (dir=4) floors 2–4 if (floor > 1) { for (let i = 0; i < 8; i++) { const idx = String(i+1).padStart(2,'0'); const code = `${floor}4${idx}`; const y = gap + (i + 0.5)*((innerH - size)/8) + size/2; mk(gap/2, y, code); } } // EAST (dir=2) only B-Mod floors 2–4 if (isBmod && floor > 1) { for (let i = 0; i < 8; i++) { const idx = String(i+1).padStart(2,'0'); const code = `${floor}2${idx}`; const y = gap + (i + 0.5)*((innerH - size)/8) + size/2; mk(W - gap/2, y, code); } } } // ==== SIMPLIFIED AFE DRAW ==== function drawAFE(regionEl, floor) { // remove old regionEl.querySelectorAll('.station-square').forEach(sq => sq.remove()); if (floor === 1) { for (let i = 1; i <= 21; i++) { const sq = document.createElement('div'); sq.className = 'station-square'; sq.textContent = String(i); regionEl.appendChild(sq); } } else if (floor === 2) { for (let i = 30; i <= 71; i++) { const sq = document.createElement('div'); sq.className = 'station-square'; sq.textContent = String(i); regionEl.appendChild(sq); } } // floors 3 & 4: no stations } // ==== COLORING ==== function colorCurrentFloor(floor) { // dock-doors dockDoors.forEach(d => { const arr = doorData[d]; if (!arr.length) return; const latest = arr[arr.length - 1]; if (!latest.stationDockDoor.startsWith(String(floor))) return; const sq = document.querySelector(`.dock-square[data-door="${d}"]`); if (sq) sq.style.background = getColorFromHI(latest.heatIndex); }); // regions and AFE Object.entries(regionEls).forEach(([regionName, el]) => { regionData[regionName] .filter(r => r.stationDockDoor.startsWith(String(floor))) .forEach(r => { const sq = Array.from(el.getElementsByClassName('station-square')) .find(s => s.textContent === r.stationDockDoor); if (sq) sq.style.background = getColorFromHI(r.heatIndex); }); }); } // ==== FLOOR SWITCHING ==== function setupFloorSelector(cb) { document.getElementsByName('floor').forEach(rb => { rb.addEventListener('change', () => cb(rb.value)); }); } function renderFloor(floor) { drawRing(regionEls['A-Mod'], +floor, false); drawAFE (regionEls['Outbound Pre-Slam'],+floor); drawRing(regionEls['B-Mod'], +floor, true); colorCurrentFloor(floor); } // ==== REAL-TIME SSE ==== function subscribeRealtime() { const es = new EventSource('/api/stream'); es.addEventListener('new-reading', e => { const r = JSON.parse(e.data); const sq = document.querySelector(`.dock-square[data-door="${r.stationDockDoor}"]`); if (sq) sq.style.background = getColorFromHI(r.heatIndex); }); es.addEventListener('new-area-reading', e => { const r = JSON.parse(e.data); const el = regionEls['Outbound Pre-Slam']; Array.from(el.getElementsByClassName('station-square')).forEach(sq => { if (sq.textContent === r.stationDockDoor) { sq.style.background = getColorFromHI(r.heatIndex); } }); }); } // ==== TOOLTIP SETUP ==== function setupTooltips() { const diagram = document.getElementById('diagram'); diagram.addEventListener('mousemove', e => { const tgt = e.target; // dock-door if (tgt.classList.contains('dock-square')) { const d = Number(tgt.dataset.door); const stats = computeStats(doorData[d]); if (!stats) return hideTooltip(); const { latest, max, min, avg } = stats; const html = ` Door ${d}
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%
Time: ${new Date(latest.timestamp).toLocaleString()}
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}
Avg HI: ${avg} `; return showTooltip(html, e.pageX, e.pageY); } // station-square if (tgt.classList.contains('station-square')) { let stats = null, regionName = null; for (const [name, arr] of Object.entries(regionData)) { if (arr.some(r => r.stationDockDoor === tgt.textContent)) { regionName = name; stats = computeStats(arr.filter(r => r.stationDockDoor === tgt.textContent)); break; } } if (!stats) return hideTooltip(); const { latest, max, min, avg } = stats; const html = ` ${regionName} ${tgt.textContent}
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%
Time: ${new Date(latest.timestamp).toLocaleString()}
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}
Avg HI: ${avg} `; return showTooltip(html, e.pageX, e.pageY); } hideTooltip(); }); diagram.addEventListener('mouseleave', hideTooltip); } // ==== INITIALIZATION ==== document.addEventListener('DOMContentLoaded', async () => { buildDockRow(); await loadInitial(); setupFloorSelector(val => renderFloor(val)); renderFloor(1); subscribeRealtime(); setupTooltips(); });