From a9ed3671484510750fd8f7ad315ac0cb764ccaaf Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 21 May 2026 13:00:09 +0100 Subject: [PATCH] feat: add SensorDashboard JS component --- static/js/components/sensor-dashboard.js | 203 +++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 static/js/components/sensor-dashboard.js diff --git a/static/js/components/sensor-dashboard.js b/static/js/components/sensor-dashboard.js new file mode 100644 index 0000000..fa015fb --- /dev/null +++ b/static/js/components/sensor-dashboard.js @@ -0,0 +1,203 @@ +const SensorDashboard = (function () { + 'use strict'; + + const STORAGE_KEY = 'sensorView'; + const MAX_SPARK_PTS = 30; + + // Map + const devices = new Map(); + + // ---- Helpers ---- + + function esc(str) { + return String(str) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function formatAge(timestamp) { + if (!timestamp) return ''; + const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp); + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 10) return 'just now'; + if (s < 60) return `${s}s ago`; + return `${Math.floor(s / 60)}m ago`; + } + + function isRecent(timestamp) { + if (!timestamp) return false; + const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp); + return (Date.now() - ts) < 10000; + } + + // ---- Primary value for sparkline ---- + + function getPrimary(msg) { + if (msg.temperature !== undefined) + return { value: msg.temperature, color: '#f59e0b' }; + if (msg.pressure !== undefined) + return { value: msg.pressure, color: '#a78bfa' }; + if (msg.wind_speed !== undefined) + return { value: msg.wind_speed, color: '#4aa3ff' }; + return null; + } + + function getFlashClass(msg) { + return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple'; + } + + // ---- HTML builders ---- + + function buildReadingsHTML(msg) { + // State-only device (no continuous numeric field) + if (msg.state !== undefined && msg.temperature === undefined + && msg.pressure === undefined && msg.wind_speed === undefined) { + const raw = String(msg.state); + const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active'; + return `
+ + ${esc(raw.toUpperCase())} +
`; + } + + const parts = []; + if (msg.temperature !== undefined) + parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' }); + if (msg.humidity !== undefined) + parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' }); + if (msg.pressure !== undefined) + parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' }); + if (msg.wind_speed !== undefined) + parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' }); + if (msg.rain !== undefined) + parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' }); + + if (parts.length === 0) + return `
No numeric data
`; + + return parts.map(p => ` +
+
${p.val}
+
${esc(p.unit)}
+
${p.label}
+
`).join(''); + } + + function buildSparklineHTML(history, color) { + if (history.length < 2) + return `
Collecting data…
`; + + const W = 120, H = 22, PAD = 2; + const min = Math.min(...history); + const max = Math.max(...history); + const range = max - min || 1; + const pts = history.map((v, i) => { + const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD; + const y = H - PAD - ((v - min) / range) * (H - PAD * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + const last = pts.split(' ').pop().split(','); + return ` + + + + `; + } + + function buildCardHTML(msg, history, primaryColor) { + const age = formatAge(msg.timestamp); + const fresh = isRecent(msg.timestamp); + const batOk = msg.battery === 'OK'; + const batLow = msg.battery === 'LOW'; + const sparkHTML = history.length > 0 + ? buildSparklineHTML(history, primaryColor || '#4aa3ff') + : `
Waiting for data…
`; + + return ` +
+
+
${esc(msg.model || 'Unknown')}
+
ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${msg.channel}` : ''}
+
+
${age}
+
+
${buildReadingsHTML(msg)}
+
${sparkHTML}
+ `; + } + + // ---- Public: reading hook ---- + + function addReading(msg) { + const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`; + const primary = getPrimary(msg); + + if (devices.has(key)) { + const dev = devices.get(key); + if (primary) { + dev.history.push(primary.value); + if (dev.history.length > MAX_SPARK_PTS) dev.history.shift(); + dev.primaryColor = primary.color; + } + dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor); + const cls = getFlashClass(msg); + dev.card.classList.add(cls); + setTimeout(() => dev.card.classList.remove(cls), 820); + } else { + const history = primary ? [primary.value] : []; + const grid = document.getElementById('sensorDashboardGrid'); + if (!grid) return; + const card = document.createElement('div'); + card.className = 'sdb-card sdb-card--new'; + card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff'); + grid.insertBefore(card, grid.firstChild); + setTimeout(() => card.classList.remove('sdb-card--new'), 2000); + devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' }); + } + } + + // ---- Show / hide / reset ---- + + function applyViewState(mode) { + const view = document.getElementById('sensorDashboardView'); + const output = document.getElementById('output'); + + if (mode === 'sensor') { + const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard'; + const isDash = saved === 'dashboard'; + if (view) view.style.display = isDash ? 'block' : 'none'; + if (output) output.style.display = isDash ? 'none' : ''; + _updateToggle(isDash); + } else { + if (view) view.style.display = 'none'; + if (output) output.style.display = ''; + } + } + + function show() { + localStorage.setItem(STORAGE_KEY, 'dashboard'); + applyViewState('sensor'); + } + + function hide() { + localStorage.setItem(STORAGE_KEY, 'feed'); + applyViewState('sensor'); + } + + function _updateToggle(isDash) { + document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash); + document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash); + } + + function reset() { + devices.clear(); + const grid = document.getElementById('sensorDashboardGrid'); + if (grid) grid.innerHTML = ''; + } + + return { addReading, show, hide, reset, applyViewState }; +})();