2025-04-29 08:16:29 -04:00

269 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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