diff --git a/.env b/.env index 4a32580..81e12fb 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ # Server port PORT=3000 -WEATHER_API_KEY=openweathermap_api_key -ZIP_CODE=00000 +WEATHER_API_KEY=27a2e8429bdc47104adb6572ef9f7ad9 +ZIP_CODE=21224 # MariaDB connection DB_CLIENT=mysql diff --git a/public/heatmap.html b/public/heatmap.html index 38dc20c..84e6552 100644 --- a/public/heatmap.html +++ b/public/heatmap.html @@ -8,39 +8,53 @@ Heat Map +
+ + | Fuego - Heat Tracker + + + + +
- -
- - | Fuego - Heat Tracker - - - - -
+
-
-
-
- - -
- - -
-
A Mod
-
Outbound Pre-Slam
-
B Mod
-
- - -
- -
-
-
+ +
+ + + +
- +
+
+ + +
+ + +
+
+
AR Floor
A MOD
+
+
+
Outbound Pre-Slam
AFE
+
+
+
AR Floor
B MOD
+
+
+ + +
+
+
+ + +
+
+ + diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 63cd579..0000000 --- a/public/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - Fuego – Heat Tracker - - - - - -
- -
- -
- - -
- - - - - - - - - - - - - - - -
Date/TimeTemperatureHumidityHeat IndexLocationDirection
-
-
- - - - - - diff --git a/public/scripts/heatmap.js b/public/scripts/heatmap.js index f0b3d27..bab9f67 100644 --- a/public/scripts/heatmap.js +++ b/public/scripts/heatmap.js @@ -1,36 +1,30 @@ // public/scripts/heatmap.js -// ==== Configuration ==== +// ==== 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), + ...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'), + '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 +const doorData = {}; // dockDoor → [readings] +const regionData = {}; // regionName → [readings] dockDoors.forEach(d => doorData[d] = []); Object.keys(regionEls).forEach(r => regionData[r] = []); -// ==== Color Helper ==== +// ==== HELPERS ==== function getColorFromHI(H) { const pct = Math.min(Math.max((H - 70) / 30, 0), 1); - const r = 255; - const g = Math.round(255 * (1 - pct)); + const r = 255, 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]; @@ -39,161 +33,236 @@ function computeStats(arr) { 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 }; + return { + latest: arr[arr.length - 1], + max, + min, + avg: (sum / arr.length).toFixed(2) + }; } -// ==== Grid Creation ==== -function createGrid() { +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.className = 'dock-square'; sq.dataset.door = d; - sq.textContent = 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); +// ==== 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); } - }); - // 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); + } + // 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); }); - // Color each region - Object.entries(regionEls).forEach(([r, el]) => { - const stats = computeStats(regionData[r]); - if (stats) el.style.background = getColorFromHI(stats.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); + }); }); } -// ==== SSE Subscriptions ==== +// ==== 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); - 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); - } + 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); - if (regionData[r.region]) { - regionData[r.region].push(r); - const el = regionEls[r.region]; - el.style.background = getColorFromHI(r.heatIndex); - } + 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 ==== +// ==== 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 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; - const lt = new Date(stats.latest.timestamp).toLocaleString(); - const ht = new Date(stats.max.timestamp).toLocaleString(); - const lt2= new Date(stats.min.timestamp).toLocaleString(); + if (!stats) return hideTooltip(); + const { latest, max, min, avg } = stats; 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} + 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} `; - 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`); + 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); } -// ==== Init ==== -document.addEventListener('DOMContentLoaded', () => { - createGrid(); - loadInitial(); +// ==== INITIALIZATION ==== +document.addEventListener('DOMContentLoaded', async () => { + buildDockRow(); + await loadInitial(); + setupFloorSelector(val => renderFloor(val)); + renderFloor(1); subscribeRealtime(); setupTooltips(); - setupZoom(); }); diff --git a/public/scripts/trends.js b/public/scripts/trends.js index 6dca130..a6e1cc2 100644 --- a/public/scripts/trends.js +++ b/public/scripts/trends.js @@ -1,13 +1,13 @@ -// average helper +// public/scripts/trends.js + +// Helper to compute average function average(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; } -// interval mappings +// Period definitions const periodKeys = ['hourly','daily','weekly','monthly','yearly']; const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly']; - -// bucket definitions const periodConfig = { hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') }, daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k }, @@ -20,70 +20,63 @@ const periodConfig = { yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k } }; -// global Chart.js instance let trendChart, dataTable; -// fetch readings from server +// Fetch all readings from server async function fetchReadings() { const res = await fetch('/api/readings'); return res.ok ? res.json() : []; } -// determine direction based on dock door # -function getDirection(dock) { - return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) ) - ? 'Inbound' - : 'Outbound'; -} - -// render DataTable +// Render the DataTable under the chart function renderTable(allReadings) { const tbody = $('#trendTable tbody').empty(); allReadings.forEach(r => { - const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'Europe/London' }); - const dir = getDirection(r.dockDoor); + const ts = new Date(r.timestamp) + .toLocaleString('en-US',{timeZone:'America/New_York'}); + const loc = r.stationDockDoor; // dock door or station code + const dir = r.location; // Inbound, Outbound, A-Mod, AFE-1... tbody.append(` ${ts} ${r.temperature.toFixed(1)} ${r.humidity.toFixed(1)} ${r.heatIndex.toFixed(1)} - ${r.dockDoor} + ${loc} ${dir} `); }); - // initialize or redraw DataTable if ($.fn.DataTable.isDataTable('#trendTable')) { dataTable.clear().rows.add($('#trendTable tbody tr')).draw(); } else { dataTable = $('#trendTable').DataTable({ - paging: true, + paging: true, pageLength: 25, - ordering: true, - order: [[0,'desc']], - autoWidth: false, - scrollX: true + ordering: true, + order: [[0,'desc']], + autoWidth: false, + scrollX: true }); } } -// draw or update chart +// Build or update the Chart.js line chart async function drawTrend() { - const all = await fetchReadings(); - const slider = document.getElementById('periodSlider'); - const periodKey = periodKeys[slider.value]; - const cfg = periodConfig[periodKey]; + const all = await fetchReadings(); + const slider = document.getElementById('periodSlider'); + const key = periodKeys[slider.value]; + const cfg = periodConfig[key]; - // group & stats + // Group readings by time bucket const groups = {}; all.forEach(r => { - const key = cfg.keyFn(r); - groups[key] = groups[key] || { temps:[], hums:[], his:[] }; - groups[key].temps.push(r.temperature); - groups[key].hums.push(r.humidity); - groups[key].his.push(r.heatIndex); + const k = cfg.keyFn(r); + groups[k] = groups[k] || { temps:[], hums:[], his:[] }; + groups[k].temps.push(r.temperature); + groups[k].hums.push(r.humidity); + groups[k].his.push(r.heatIndex); }); const labels = Object.keys(groups).sort(); @@ -96,9 +89,9 @@ async function drawTrend() { }; }); - // selected toggles - const checks = Array.from(document.querySelectorAll('#metricToggles input:checked')); - const datasets = checks.map(chk => { + // Create datasets from checked toggles + const checked = Array.from(document.querySelectorAll('#metricToggles input:checked')); + const datasets = checked.map(chk => { const m = chk.dataset.metric, s = chk.dataset.stat; return { label: `${m.toUpperCase()} ${s.toUpperCase()}`, @@ -116,17 +109,14 @@ async function drawTrend() { } else { trendChart = new Chart(ctx, { type: 'line', - data: { - labels: labels.map(cfg.labelFn), - datasets - }, + data: { labels: labels.map(cfg.labelFn), datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true }, tooltip: { mode:'index', intersect:false }, - zoom: { + zoom: { pan: { enabled:true, mode:'x', modifierKey:'ctrl' }, zoom: { wheel:{enabled:true}, pinch:{enabled:true}, mode:'x' } } @@ -139,23 +129,20 @@ async function drawTrend() { }); } - // always update table below + // Always update the table below renderTable(all); } -// wire up controls +// Wire up controls and initial draw document.addEventListener('DOMContentLoaded', () => { - // initial draw drawTrend(); - // slider document.getElementById('periodSlider') .addEventListener('input', e => { document.getElementById('periodLabel').textContent = periodLabels[e.target.value]; drawTrend(); }); - // toggles document.querySelectorAll('#metricToggles input') .forEach(chk => chk.addEventListener('change', drawTrend)); -}); \ No newline at end of file +}); diff --git a/public/styles.css b/public/styles.css index b520312..205e6cf 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,39 +1,26 @@ /* ================= CSS VARIABLES ================= */ :root { - --square-size: 60px; - --gap: 8px; - --v-gap: 24px; - --warehouse-height: 200px; + --square-size: 60px; + --gap: 8px; + --v-gap: 40px; + --warehouse-height: 200px; + --region-height: 480px; } -/* ================= GLOBAL & LAYOUT ================= */ +/* ================= GLOBAL & RESET ================= */ html, body { height: 100%; margin: 0; padding: 0; - background: #f4f4f4; font-family: Arial, sans-serif; -} -h1 { - text-align: center; - margin-bottom: 1rem; -} -.page-container { - margin-top: 70px; /* under fixed header */ - width: 95%; - max-width: 1200px; - margin-left: auto; - margin-right: auto; - padding: 1rem; - background: #fff; - border-radius: 6px; + background: #f4f4f4; box-sizing: border-box; - display: flex; - flex-direction: column; - min-height: calc(100vh - 70px); +} +*, *::before, *::after { + box-sizing: inherit; } -/* ================= BINS HEADER & NAV BUTTONS ================= */ +/* ================= HEADER & NAV ================= */ .main-header { background-color: #232F3E; display: flex; @@ -67,8 +54,27 @@ h1 { background-color: #e48f00; } +/* ================= PAGE & CONTAINERS ================= */ +.page-container, +.heatmap-container { + display: flex; + flex-direction: column; + margin-top: 70px; /* height of header */ +} +.page-container { + width: 95%; + max-width: 1200px; + margin: 0 auto; + padding: 1rem; + background: #fff; + border-radius: 6px; +} +.heatmap-container { + height: calc(100vh - 70px); +} + /* ================= FORM STYLES ================= */ -.form-input { +.form-input, .form-textarea { width: 80%; max-width: 400px; margin: 1rem auto; @@ -102,56 +108,46 @@ legend { font-weight: bold; } -/* ================= TRENDS PAGE ================= */ -/* Controls */ -#trend-controls { - flex: 0 0 auto; +/* ================= FLOOR SELECTOR ================= */ +.floor-selector { text-align: center; - margin-bottom: 1rem; + padding: 0.5rem 0; + background: #fafafa; + border-bottom: 1px solid #ddd; } -/* Chart */ -.chart-container { - flex: 1 1 auto; - width: 100%; - position: relative; +.floor-selector label { + margin: 0 1rem; + font-weight: bold; + cursor: pointer; } -.chart-container canvas { - width: 100% !important; - height: 100% !important; -} -/* Table */ -.table-container { - margin-top: 2rem; - overflow-x: auto; -} -table.dataTable { - width: 100% !important; +.floor-selector input { + margin-right: 0.25rem; } -/* ================= HEATMAP STYLES ================= */ -/* Scroll & pan wrapper */ -.heatmap-wrapper { - width: 100%; - height: 100%; - overflow-x: auto; - overflow-y: hidden; - display: flex; - justify-content: flex-start; - align-items: center; +/* ================= HEATMAP SCROLL & ZOOM ================= */ +.heatmap-scroll { + flex: 1; + overflow: auto; + position: relative; } -/* Entire diagram */ + +/* ================= DIAGRAM LAYOUT ================= */ .diagram-container { + width: calc((var(--square-size) * 83) + (var(--gap) * 82)); display: flex; flex-direction: column; align-items: center; gap: var(--v-gap); + padding: var(--v-gap); + box-sizing: border-box; } + /* Dock-door row */ .dock-row { display: flex; gap: var(--gap); + flex-wrap: nowrap; } -/* Each dock square */ .dock-square { width: var(--square-size); height: var(--square-size); @@ -162,40 +158,82 @@ table.dataTable { justify-content: center; font-size: 0.75rem; color: #333; - cursor: pointer; user-select: none; } -/* Regions below dock row */ + +/* ================= REGIONS ================= */ .regions-container { display: flex; width: 100%; gap: var(--gap); } .region { + position: relative; flex: 1; - height: 120px; + height: var(--region-height); border: 2px solid #666; border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-weight: bold; - user-select: none; + overflow: hidden; } .region-amod { background: #eef; } .region-preslam { background: #efe; } .region-bmod { background: #fee; } -/* Warehouse rectangle */ + +/* AFE override: line up station squares horizontally */ +#region-preslam { + display: flex !important; + align-items: center; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + padding: var(--gap) 0; +} +#region-preslam .station-square { + position: static; + flex: 0 0 auto; + margin: calc(var(--gap)/2); +} + +/* ================= CENTER LABEL ================= */ +.region .ar-center { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%,-50%); + white-space: pre-line; + text-align: center; + font-size: 1.2rem; + font-weight: bold; + color: rgba(0,0,0,0.6); + pointer-events: none; +} + +/* ================= STATION SQUARES ================= */ +.region .station-square { + position: absolute; + width: var(--square-size); + height: var(--square-size); + line-height: var(--square-size); + text-align: center; + font-size: 0.75rem; + color: #111; + background: #ddd; + border: 1px solid #aaa; + border-radius: 4px; + box-shadow: 1px 1px 3px rgba(0,0,0,0.2); + cursor: pointer; + user-select: none; + transition: background 0.3s; +} + +/* ================= WAREHOUSE RECTANGLE ================= */ .warehouse { - width: calc( - (var(--square-size) * 83) + /* adjust door count if needed */ - (var(--gap) * 82) - ); + width: calc((var(--square-size) * 83) + (var(--gap) * 82)); height: var(--warehouse-height); background: #ccc; border-radius: 6px; } -/* Hover tooltip */ + +/* ================= TOOLTIP ================= */ .tooltip { position: absolute; pointer-events: none; @@ -211,5 +249,5 @@ table.dataTable { /* ================= UTILITY ================= */ .hidden { - display: none; + display: none !important; } diff --git a/server.js b/server.js index 80fd67c..5c0527f 100644 --- a/server.js +++ b/server.js @@ -10,114 +10,110 @@ const { uploadTrendsCsv } = require('./s3'); const app = express(); const PORT = process.env.PORT || 3000; -// In-memory shift counters +// In‐memory shift counters const shiftCounters = {}; -/** Helpers **/ +// ===== Helpers ===== +const pad2 = n => n.toString().padStart(2, '0'); -// Format a JS Date in EST as SQL DATETIME -function formatDateEST(date) { - const pad = n => n.toString().padStart(2,'0'); - return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` + - `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +// Format Date in EST as “M/D/YY @HH:mm” +function shortEST(d) { + const est = new Date(d.toLocaleString('en-US', { timeZone: 'America/New_York' })); + const M = est.getMonth() + 1, D = est.getDate(), YY = String(est.getFullYear()).slice(-2); + const hh = pad2(est.getHours()), mm = pad2(est.getMinutes()); + return `${M}/${D}/${YY} @${hh}:${mm}`; } -// Format a JS Date in EST as an ISO-like string (no Z) -function isoStringEST(date) { - const pad = n => n.toString().padStart(2,'0'); - return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` + - `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +// Format Date in EST as SQL DATETIME +function formatDateEST(d) { + const est = new Date(d.toLocaleString('en-US', { timeZone: 'America/New_York' })); + const y = est.getFullYear(), M = pad2(est.getMonth()+1), D = pad2(est.getDate()); + const hh = pad2(est.getHours()), mm = pad2(est.getMinutes()), ss = pad2(est.getSeconds()); + return `${y}-${M}-${D} ${hh}:${mm}:${ss}`; } -// Compute heat index (NOAA formula) +// Compute heat index (NOAA) function computeHeatIndex(T, R) { const [c1,c2,c3,c4,c5,c6,c7,c8,c9] = - [-42.379,2.04901523,10.14333127,-0.22475541, - -0.00683783,-0.05481717,0.00122874,0.00085282,-0.00000199]; + [-42.379,2.04901523,10.14333127,-0.22475541, + -0.00683783,-0.05481717,0.00122874,0.00085282,-0.00000199]; const HI = c1 + c2*T + c3*R + c4*T*R - + c5*T*T + c6*R*R + c7*T*T*R - + c8*T*R*R + c9*T*T*R*R; - return Math.round(HI*100)/100; + + c5*T*T + c6*R*R + c7*T*T*R + + c8*T*R*R + c9*T*T*R*R; + return Math.round(HI * 100) / 100; } -// Determine shift (Day/Night), shiftStart, key & estNow in EST +// Determine shift info in EST function getShiftInfo(now) { - const estNow = new Date(now.toLocaleString('en-US',{ timeZone:'America/New_York' })); - const [h,m] = [estNow.getHours(), estNow.getMinutes()]; - let shift, shiftStart = new Date(estNow); + const est = new Date(now.toLocaleString('en-US', { timeZone:'America/New_York' })); + const h = est.getHours(), m = est.getMinutes(); + let shift, start = new Date(est); - if (h>7 || (h===7 && m>=0)) { - if (h<17 || (h===17 && m<30)) { - shift = 'Day'; shiftStart.setHours(7,0,0,0); + if (h > 7 || (h === 7 && m >= 0)) { + if (h < 17 || (h === 17 && m < 30)) { + shift = 'Day'; start.setHours(7,0,0,0); } else { - shift = 'Night'; shiftStart.setHours(17,30,0,0); + shift = 'Night'; start.setHours(17,30,0,0); } } else { shift = 'Night'; - shiftStart.setDate(shiftStart.getDate()-1); - shiftStart.setHours(17,30,0,0); + start.setDate(start.getDate()-1); + start.setHours(17,30,0,0); } - const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` + - `${shiftStart.getHours()}${shiftStart.getMinutes()}`; - return { shift, shiftStart, key, estNow }; + const key = `${shift}-${start.toISOString().slice(0,10)}-${start.getHours()}${start.getMinutes()}`; + return { shift, start, key, estNow: est }; } // Fetch current weather from OpenWeatherMap async function fetchCurrentWeather() { - const apiKey = process.env.WEATHER_API_KEY; - const zip = process.env.ZIP_CODE; - if (!apiKey || !zip) return null; + const key = process.env.WEATHER_API_KEY; + const zip = process.env.ZIP_CODE; + if (!key || !zip) return null; try { const { data } = await axios.get( - 'https://api.openweathermap.org/data/2.5/weather', - { params: { zip:`${zip},us`, appid:apiKey, units:'imperial' } } + 'https://api.openweathermap.org/data/2.5/weather', + { params: { zip:`${zip},us`, appid:key, units:'imperial' } } ); - return { - description: data.weather[0].description, - humidity: data.main.humidity, - temperature: data.main.temp - }; - } catch (err) { - console.error('Weather API error:', err.message); + const desc = data.weather[0].description; + const hi = Math.round(data.main.temp_max); + const hum = data.main.humidity; + return `${desc.charAt(0).toUpperCase()+desc.slice(1)}. Hi of ${hi}, Humidity ${hum}%`; + } catch (e) { + console.error('Weather API error:', e.message); return null; } } -/** MariaDB pool **/ +// MariaDB pool const pool = mysql.createPool({ host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT,10)||3306, + port: parseInt(process.env.DB_PORT,10) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, - connectTimeout: 10000, - enableKeepAlive: true, - keepAliveInitialDelay:10000 + connectTimeout: 10000 }); -// Ensure readings table exists (for dock doors & areas) -(async ()=>{ - const createSQL = ` +// Ensure table exists +(async () => { + const sql = ` CREATE TABLE IF NOT EXISTS readings ( - id INT AUTO_INCREMENT PRIMARY KEY, - dockDoor INT, - direction VARCHAR(10), - region VARCHAR(20), - stationCode VARCHAR(10), - timestamp DATETIME NOT NULL, - temperature DOUBLE, - humidity DOUBLE, - heatIndex DOUBLE - ); - `; - await pool.execute(createSQL); + id INT AUTO_INCREMENT PRIMARY KEY, + location VARCHAR(20) NOT NULL, + stationDockDoor VARCHAR(10) NOT NULL, + timestamp DATETIME NOT NULL, + temperature DOUBLE, + humidity DOUBLE, + heatIndex DOUBLE + );`; + await pool.execute(sql); })(); -/** SSE for real-time updates **/ +// SSE setup let clients = []; app.get('/api/stream',(req,res)=>{ res.set({ @@ -127,183 +123,190 @@ app.get('/api/stream',(req,res)=>{ }); res.flushHeaders(); clients.push(res); - req.on('close',()=>{ clients=clients.filter(c=>c!==res); }); + req.on('close',()=>{ clients = clients.filter(c=>c!==res); }); }); -function broadcast(event,data){ - const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +function broadcast(evt,data){ + const msg = `event: ${evt}\ndata: ${JSON.stringify(data)}\n\n`; clients.forEach(c=>c.write(msg)); } -// Middleware & static +// Middleware app.use(bodyParser.json()); app.use(express.static(path.join(__dirname,'public'))); -/** Dual-dock-door readings **/ -app.post('/api/readings',async(req,res)=>{ +const publicDir = path.join(__dirname, 'public'); +// Serve heatmap.html as the index page +app.use(express.static(publicDir, { index: 'heatmap.html' })); + + +// ---- Dual dock-door endpoint ---- +app.post('/api/readings', async (req, res) => { try { const { inbound={}, outbound={} } = req.body; - const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound; - const { dockDoor: outDoor,temperature: outTemp,humidity: outHum } = outbound; - if ([inDoor,inTemp,inHum,outDoor,outTemp,outHum].some(v=>v===undefined)) - return res.status(400).json({error:'Missing inbound/outbound fields'}); + const { dockDoor: inD, temperature: inT, humidity: inH } = inbound; + const { dockDoor: outD, temperature: outT, humidity: outH } = outbound; + if ([inD,inT,inH,outD,outT,outH].some(v=>v==null)) + return res.status(400).json({ error:'Missing fields' }); - const hiIn = computeHeatIndex(inTemp,inHum); - const hiOut = computeHeatIndex(outTemp,outHum); - const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); - shiftCounters[key]=(shiftCounters[key]||0)+1; + const hiIn = computeHeatIndex(inT,inH); + const hiOut = computeHeatIndex(outT,outH); + const now = new Date(); + const { shift, start, key, estNow } = getShiftInfo(now); + shiftCounters[key] = (shiftCounters[key]||0)+1; const period = shiftCounters[key]; const sqlTs = formatDateEST(estNow); - const bcTs = isoStringEST(estNow); + const shortTs = shortEST(estNow); - // Insert dock readings - const insertSQL = ` - INSERT INTO readings - (dockDoor,direction,timestamp,temperature,humidity,heatIndex) - VALUES (?,?,?,?,?,?) - `; - await pool.execute(insertSQL,[inDoor,'inbound',sqlTs,inTemp,inHum,hiIn]); - await pool.execute(insertSQL,[outDoor,'outbound',sqlTs,outTemp,outHum,hiOut]); + // Insert inbound & outbound + const ins = `INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex) + VALUES(?,?,?,?,?,?)`; + await pool.execute(ins, ['Inbound', String(inD), sqlTs, inT, inH, hiIn]); + await pool.execute(ins, ['Outbound', String(outD),sqlTs, outT,outH,hiOut]); - // Broadcast SSE - broadcast('new-reading',{ dockDoor:inDoor, direction:'inbound',timestamp:bcTs, - temperature:inTemp, humidity:inHum, heatIndex:hiIn }); - broadcast('new-reading',{ dockDoor:outDoor,direction:'outbound',timestamp:bcTs, - temperature:outTemp,humidity:outHum,heatIndex:hiOut }); + // SSE + broadcast('new-reading', { location:'Inbound', stationDockDoor:String(inD), timestamp: shortTs, temperature:inT, humidity:inH, heatIndex:hiIn }); + broadcast('new-reading', { location:'Outbound', stationDockDoor:String(outD), timestamp: shortTs, temperature:outT, humidity:outH, heatIndex:hiOut }); - // Build CSV & upload - const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; - const [rows] = await pool.execute( - `SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC` - ); + // CSV upload + const y=estNow.getFullYear(), m=pad2(estNow.getMonth()+1), d=pad2(estNow.getDate()); + const dateKey = `${y}${m}${d}`; + const [rows] = await pool.execute(`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`); let csvUrl=null; - try{ csvUrl = await uploadTrendsCsv(dateKey,rows); } - catch(e){ console.error('CSV upload error:',e); } + try { csvUrl = await uploadTrendsCsv(dateKey, rows); } + catch(e){ console.error('CSV upload error',e); } // Weather - const weather = await fetchCurrentWeather(); + const weather = await fetchCurrentWeather() || 'Unavailable'; // Slack payload - const slackPayload = { - text: 'New temperature readings recorded', - inbound_dockDoor: inDoor, - inbound_temperature: inTemp, - inbound_humidity: inHum, - hiIn, - outbound_dockDoor: outDoor, - outbound_temperature:outTemp, - outbound_humidity: outHum, - hiOut, + const text = `*_${shift} shift Period ${period} dock/ trailer temperature checks for ${shortTs}_*\n` + + `*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` + + `*_⬇️ Inbound Dock Door 🚛 :_* ${inD}\n` + + `*_Temp:_* ${inT} °F 🌡️\n` + + `*_Humidity:_* ${inH} % 💦\n` + + `*_Heat Index:_* ${hiIn} °F 🥵\n\n` + + `*_⬆️ Outbound Dock Door 🚛 :_* ${outD}\n` + + `*_Temp:_* ${outT} °F 🌡️\n` + + `*_Humidity:_* ${outH} % 💦\n` + + `*_Heat Index:_* ${hiOut} °F 🥵`; + + const payload = { + text, shift, period, - timestamp: bcTs, - csvUrl, - current_weather: weather?.description || null, - current_humidity: weather?.humidity || null, - current_temperature: weather?.temperature || null + timestamp: shortTs, + current_weather: weather, + inbound_dock_door: inD, + inbound_temperature: inT, + inbound_humidity: inH, + inbound_heat_index: hiIn, + outbound_dock_door: outD, + outbound_temperature: outT, + outbound_humidity: outH, + outbound_heat_index: hiOut }; - await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload); + await axios.post(process.env.SLACK_WEBHOOK_URL, payload); - res.json({ success:true, shift,period,csvUrl }); - } catch(err) { - console.error('POST /api/readings error:',err); + res.json({ success:true, shift, period, csvUrl }); + } catch (err) { + console.error('POST /api/readings error:', err); res.status(500).json({ error: err.message }); } }); -/** Area (A-Mod / AFE-1 / AFE-2 / B-Mod) readings **/ -app.post('/api/area-readings',async(req,res)=>{ +// ---- Area (Mod/AFE) endpoint ---- +app.post('/api/area-readings', async (req, res) => { try { - const { area, stationCode, temperature, humidity } = req.body; - if (!area||!stationCode||temperature==null||humidity==null) - return res.status(400).json({error:'Missing area,stationCode,temp,humidity'}); + const { area, stationCode, temperature: T, humidity: H } = req.body; + if (!area || !stationCode || T==null || H==null) + return res.status(400).json({ error:'Missing fields' }); - const hi = computeHeatIndex(temperature,humidity); - const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); - shiftCounters[key]=(shiftCounters[key]||0)+1; - const period = shiftCounters[key]; - const sqlTs = formatDateEST(estNow); - const bcTs = isoStringEST(estNow); + const hi = computeHeatIndex(T,H); + const now = new Date(); + const { shift, start, key, estNow } = getShiftInfo(now); - // Insert area reading - const sql = ` - INSERT INTO readings - (region,stationCode,timestamp,temperature,humidity,heatIndex) - VALUES (?,?,?,?,?,?) - `; - await pool.execute(sql,[area,stationCode,sqlTs,temperature,humidity,hi]); + // NOTE: area checks do NOT increment period counter + const shortTs = shortEST(estNow); + const sqlTs = formatDateEST(estNow); - // Broadcast SSE - broadcast('new-area-reading',{ region:area,stationCode,timestamp:bcTs, - temperature,humidity,heatIndex:hi }); + // Insert + const ins = `INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex) + VALUES(?,?,?,?,?,?)`; + await pool.execute(ins, [area, stationCode, sqlTs, T, H, hi]); - // Build CSV & upload - const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; - const [rows] = await pool.execute( - `SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC` - ); + // SSE + broadcast('new-area-reading', { location:area, stationDockDoor:stationCode, timestamp:shortTs, temperature:T, humidity:H, heatIndex:hi }); + + // CSV upload + const y=estNow.getFullYear(), m=pad2(estNow.getMonth()+1), d=pad2(estNow.getDate()); + const dateKey = `${y}${m}${d}`; + const [rows] = await pool.execute(`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`); let csvUrl=null; - try{ csvUrl = await uploadTrendsCsv(dateKey,rows); } - catch(e){ console.error('CSV upload error:',e); } + try { csvUrl = await uploadTrendsCsv(dateKey, rows); } + catch(e){ console.error('CSV upload error',e); } // Weather - const weather = await fetchCurrentWeather(); + const weather = await fetchCurrentWeather() || 'Unavailable'; - // Slack payload - const slackPayload = { - text: 'New area temperature reading', - area, - stationCode, - temperature, - humidity, - heatIndex: hi, + // Slack text + const text = `*_${shift} shift ${area} temp check for ${shortTs}_*\n` + + `*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` + + `*_${area.toUpperCase()} station:_* ${stationCode}\n` + + `*_Temp:_* ${T} °F 🌡️\n` + + `*_Humidity:_* ${H} % 💦\n` + + `*_Heat Index:_* ${hi} °F 🥵`; + + const payload = { + text, shift, - period, - timestamp: bcTs, - csvUrl, - current_weather: weather?.description || null, - current_humidity: weather?.humidity || null, - current_temperature: weather?.temperature || null + timestamp: shortTs, + current_weather: weather, + location: area, + station_dock_door: stationCode, + temperature: T, + humidity: H, + heat_index: hi }; - await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload); + await axios.post(process.env.SLACK_WEBHOOK_URL, payload); - res.json({ success:true, shift,period,csvUrl }); - } catch(err) { - console.error('POST /api/area-readings error:',err); + res.json({ success:true, csvUrl }); + } catch (err) { + console.error('POST /api/area-readings error:', err); res.status(500).json({ error: err.message }); } }); -/** Return all readings **/ -app.get('/api/readings',async(req,res)=>{ +// ---- GET all readings ---- +app.get('/api/readings', async (req, res) => { try { - const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); + const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`); res.json(rows); - } catch(err) { - console.error('GET /api/readings error:',err); + } catch (err) { + console.error('GET /api/readings error:', err); res.status(500).json({ error: err.message }); } }); -/** Export CSV of all readings **/ -app.get('/api/export',async(req,res)=>{ +// ---- CSV export ---- +app.get('/api/export', async (req, res) => { try { - const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); + const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`); res.setHeader('Content-disposition','attachment; filename=readings.csv'); res.set('Content-Type','text/csv'); - res.write('id,dockDoor,direction,region,stationCode,timestamp,temperature,humidity,heatIndex\n'); - rows.forEach(r=>{ + res.write('id,location,stationDockDoor,timestamp,temperature,humidity,heatIndex\n'); + rows.forEach(r => { const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp; - res.write(`${r.id},${r.dockDoor||''},${r.direction||''},${r.region||''},${r.stationCode||''},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); + res.write(`${r.id},${r.location},${r.stationDockDoor},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); }); res.end(); - } catch(err) { - console.error('GET /api/export error:',err); + } catch (err) { + console.error('GET /api/export error:', err); res.status(500).send(err.message); } }); // Start server -app.listen(PORT,()=>{ +app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });