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