269 lines
8.6 KiB
JavaScript
269 lines
8.6 KiB
JavaScript
// 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 = `
|
||
<strong>Door ${d}</strong><br>
|
||
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%<br>
|
||
Time: ${new Date(latest.timestamp).toLocaleString()}<br>
|
||
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}<br>
|
||
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}<br>
|
||
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 = `
|
||
<strong>${regionName} ${tgt.textContent}</strong><br>
|
||
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%<br>
|
||
Time: ${new Date(latest.timestamp).toLocaleString()}<br>
|
||
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}<br>
|
||
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}<br>
|
||
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();
|
||
});
|