Meta Description" name="description" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>EnviroSense — IoT Environmental Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"/>
<style>
:root {
--bg: #080e12;
--surface: #0d1a22;
--surface2: #112030;
--border: rgba(0,220,180,0.12);
--accent: #00ddb4;
--accent2: #00aaff;
--accent3: #ff6b35;
--warn: #ffb830;
--danger: #ff3b5c;
--text: #e8f4f0;
--muted: #5a7a70;
--card-glow: 0 0 40px rgba(0,221,180,0.06);
--font-display: 'Syne', sans-serif;
--font-mono: 'Space Mono', monospace;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-display);
min-height: 100vh;
overflow-x: hidden;
}
/* Animated grid background */
body::before {
content:'';
position:fixed; inset:0; z-index:0;
background-image:
linear-gradient(rgba(0,221,180,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,221,180,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events:none;
}
/* Ambient glow orbs */
body::after {
content:'';
position:fixed; inset:0; z-index:0;
background:
radial-gradient(ellipse 600px 400px at 10% 20%, rgba(0,221,180,0.05) 0%, transparent 70%),
radial-gradient(ellipse 500px 600px at 90% 70%, rgba(0,170,255,0.04) 0%, transparent 70%);
pointer-events:none;
}
.layout { position:relative; z-index:1; display:flex; min-height:100vh; }
/* === SIDEBAR === */
.sidebar {
width: 220px;
flex-shrink: 0;
background: rgba(13,26,34,0.95);
border-right: 1px solid var(--border);
display:flex; flex-direction:column;
padding: 0;
position:sticky; top:0; height:100vh;
backdrop-filter: blur(20px);
}
.logo {
padding: 24px 20px 20px;
border-bottom: 1px solid var(--border);
}
.logo-mark {
display:flex; align-items:center; gap:10px;
}
.logo-icon {
width:32px; height:32px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius:8px;
display:flex; align-items:center; justify-content:center;
font-size:16px;
box-shadow: 0 0 20px rgba(0,221,180,0.4);
}
.logo-text {
font-size: 16px;
font-weight: 800;
letter-spacing: -0.3px;
color: var(--text);
}
.logo-sub {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted);
letter-spacing: 1.5px;
margin-top:2px;
text-transform: uppercase;
}
nav { padding: 16px 0; flex:1; }
.nav-section {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 2px;
color: var(--muted);
padding: 8px 20px 6px;
text-transform: uppercase;
}
.nav-item {
display:flex; align-items:center; gap:10px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor:pointer;
border-left: 2px solid transparent;
transition: all 0.2s;
position:relative;
}
.nav-item:hover { color: var(--text); background: rgba(0,221,180,0.04); }
.nav-item.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(0,221,180,0.06);
}
.nav-item .icon { font-size:15px; width:18px; text-align:center; }
.nav-badge {
margin-left:auto;
background: var(--danger);
color:#fff;
font-family: var(--font-mono);
font-size: 9px;
padding: 2px 5px;
border-radius: 10px;
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%,100% { opacity:1; }
50% { opacity:0.6; }
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.device-status {
display:flex; align-items:center; gap:8px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
}
.status-dot {
width:7px; height:7px;
border-radius:50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%,100% { transform:scale(1); opacity:1; }
50% { transform:scale(1.3); opacity:0.7; }
}
/* === MAIN === */
.main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
/* HEADER */
.topbar {
display:flex; align-items:center; justify-content:space-between;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
background: rgba(8,14,18,0.8);
backdrop-filter: blur(20px);
position:sticky; top:0; z-index:10;
}
.topbar-left { display:flex; flex-direction:column; }
.page-title {
font-size: 20px; font-weight: 800;
letter-spacing: -0.5px;
}
.page-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
margin-top:1px;
}
.topbar-right { display:flex; align-items:center; gap:12px; }
.time-display {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
background: rgba(0,221,180,0.06);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 6px;
letter-spacing:1px;
}
.topbar-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 14px;
border-radius: 6px;
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display:flex; align-items:center; gap:6px;
}
.topbar-btn:hover { border-color: var(--accent); color: var(--accent); }
.alert-btn {
position:relative;
}
.alert-btn::after {
content:'3';
position:absolute; top:-4px; right:-4px;
background:var(--danger);
color:#fff;
font-size:9px;
width:14px; height:14px;
border-radius:50%;
display:flex; align-items:center; justify-content:center;
font-family: var(--font-mono);
}
/* CONTENT */
.content { padding: 24px 28px; flex:1; overflow-y:auto; }
/* === STAT CARDS (top row) === */
.stats-row {
display:grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
position:relative;
overflow:hidden;
cursor:pointer;
transition: all 0.25s;
box-shadow: var(--card-glow);
}
.stat-card::before {
content:'';
position:absolute; top:0; left:0; right:0;
height:2px;
background: var(--card-color, var(--accent));
border-radius:12px 12px 0 0;
}
.stat-card:hover {
border-color: rgba(0,221,180,0.25);
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(0,221,180,0.1);
}
.stat-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.5px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 10px;
display:flex; align-items:center; justify-content:space-between;
}
.stat-icon { font-size:13px; }
.stat-value {
font-size: 26px;
font-weight: 800;
letter-spacing: -1px;
line-height:1;
color: var(--card-color, var(--accent));
}
.stat-unit {
font-size: 11px;
font-weight: 400;
color: var(--muted);
margin-left:2px;
}
.stat-delta {
font-family: var(--font-mono);
font-size: 10px;
margin-top: 6px;
display:flex; align-items:center; gap:4px;
}
.delta-up { color: var(--danger); }
.delta-down { color: var(--accent); }
.delta-ok { color: var(--accent); }
.stat-bar {
margin-top:10px;
height:3px;
background: rgba(255,255,255,0.05);
border-radius:2px;
overflow:hidden;
}
.stat-bar-fill {
height:100%;
background: var(--card-color, var(--accent));
border-radius:2px;
transition: width 1s ease;
box-shadow: 0 0 8px var(--card-color, var(--accent));
}
/* === MAIN GRID === */
.main-grid {
display:grid;
grid-template-columns: 1fr 1fr 340px;
gap: 16px;
margin-bottom: 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow:hidden;
box-shadow: var(--card-glow);
}
.card-header {
display:flex; align-items:center; justify-content:space-between;
padding: 14px 18px 12px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-size: 13px; font-weight: 700;
display:flex; align-items:center; gap:7px;
}
.card-title .dot {
width:6px; height:6px; border-radius:50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
animation: pulse-dot 2s infinite;
}
.card-action {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted);
letter-spacing:1px;
text-transform:uppercase;
cursor:pointer;
transition: color 0.2s;
}
.card-action:hover { color: var(--accent); }
.card-body { padding: 16px 18px; }
/* Chart canvas area */
.chart-wrap { position:relative; }
canvas { display:block; width:100% !important; }
/* === DEVICE MAP === */
.device-map {
position:relative;
background: var(--surface2);
border-radius:8px;
height:220px;
overflow:hidden;
}
.map-grid {
position:absolute; inset:0;
background-image:
linear-gradient(rgba(0,221,180,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,221,180,0.04) 1px, transparent 1px);
background-size: 30px 30px;
}
.map-device {
position:absolute;
transform:translate(-50%,-50%);
}
.map-device-dot {
width:12px; height:12px;
border-radius:50%;
border:2px solid var(--accent);
background: rgba(0,221,180,0.3);
position:relative; z-index:2;
cursor:pointer;
transition: transform 0.2s;
}
.map-device-dot:hover { transform:scale(1.4); }
.map-device-dot.warn { border-color:var(--warn); background:rgba(255,184,48,0.3); }
.map-device-dot.danger { border-color:var(--danger); background:rgba(255,59,92,0.3); }
.map-ping {
position:absolute;
width:30px; height:30px;
border-radius:50%;
border:1px solid var(--accent);
top:50%; left:50%;
transform:translate(-50%,-50%);
animation: map-ping 2.5s infinite;
opacity:0;
}
.map-device-dot.warn + .map-ping { border-color:var(--warn); }
.map-device-dot.danger + .map-ping { border-color:var(--danger); animation-duration:1.5s; }
@keyframes map-ping {
0% { transform:translate(-50%,-50%) scale(0.3); opacity:0.8; }
100% { transform:translate(-50%,-50%) scale(2.5); opacity:0; }
}
.map-label {
position:absolute;
left:14px; top:50%; transform:translateY(-50%);
white-space:nowrap;
font-family:var(--font-mono);
font-size:9px;
color:var(--muted);
}
.map-legend {
display:flex; gap:12px;
margin-top:10px;
}
.legend-item {
display:flex; align-items:center; gap:5px;
font-family:var(--font-mono); font-size:9px; color:var(--muted);
}
.legend-dot { width:8px; height:8px; border-radius:50%; }
/* === ALERTS PANEL === */
.alerts-list { display:flex; flex-direction:column; gap:8px; }
.alert-item {
display:flex; align-items:flex-start; gap:10px;
padding: 10px 12px;
border-radius:8px;
background: var(--surface2);
border: 1px solid transparent;
transition: all 0.2s;
cursor:pointer;
}
.alert-item:hover { border-color: var(--border); }
.alert-item.danger { border-color: rgba(255,59,92,0.2); background:rgba(255,59,92,0.06); }
.alert-item.warn { border-color: rgba(255,184,48,0.2); background:rgba(255,184,48,0.05); }
.alert-item.info { border-color: rgba(0,170,255,0.2); background:rgba(0,170,255,0.04); }
.alert-icon { font-size:14px; margin-top:1px; }
.alert-content { flex:1; }
.alert-title {
font-size:11px; font-weight:700;
margin-bottom:2px;
}
.alert-item.danger .alert-title { color:var(--danger); }
.alert-item.warn .alert-title { color:var(--warn); }
.alert-item.info .alert-title { color:var(--accent2); }
.alert-desc {
font-family:var(--font-mono);
font-size:9px; color:var(--muted);
line-height:1.4;
}
.alert-time {
font-family:var(--font-mono);
font-size:9px; color:var(--muted);
white-space:nowrap;
}
/* === BOTTOM GRID === */
.bottom-grid {
display:grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* === DEVICE TABLE === */
.device-table { width:100%; border-collapse:collapse; }
.device-table th {
font-family:var(--font-mono);
font-size:9px;
color:var(--muted);
letter-spacing:1.5px;
text-transform:uppercase;
text-align:left;
padding: 0 10px 10px;
border-bottom: 1px solid var(--border);
}
.device-table td {
padding: 10px;
font-size:12px;
border-bottom: 1px solid rgba(0,221,180,0.05);
vertical-align:middle;
}
.device-table tr:last-child td { border-bottom:none; }
.device-table tr:hover td { background:rgba(0,221,180,0.02); }
.device-name { font-weight:700; }
.device-id { font-family:var(--font-mono); font-size:9px; color:var(--muted); margin-top:1px; }
.status-pill {
display:inline-flex; align-items:center; gap:4px;
font-family:var(--font-mono); font-size:9px; font-weight:700;
padding:3px 8px; border-radius:20px;
}
.status-pill.online { background:rgba(0,221,180,0.1); color:var(--accent); border:1px solid rgba(0,221,180,0.2); }
.status-pill.offline { background:rgba(255,59,92,0.1); color:var(--danger); border:1px solid rgba(255,59,92,0.2); }
.status-pill.warn { background:rgba(255,184,48,0.1); color:var(--warn); border:1px solid rgba(255,184,48,0.2); }
.signal-bars {
display:flex; align-items:flex-end; gap:2px; height:14px;
}
.bar { width:4px; border-radius:1px; background:var(--muted); }
.bar.active { background:var(--accent); }
/* === GAUGE === */
.gauges-grid {
display:grid; grid-template-columns:repeat(3,1fr); gap:12px;
}
.gauge-item { text-align:center; }
.gauge-svg { display:block; margin:0 auto; }
.gauge-label {
font-family:var(--font-mono);
font-size:9px; color:var(--muted);
margin-top:4px; letter-spacing:1px;
text-transform:uppercase;
}
.gauge-val {
font-size:14px; font-weight:800;
margin-top:2px;
}
/* SCROLLBAR */
.content::-webkit-scrollbar { width:4px; }
.content::-webkit-scrollbar-track { background:transparent; }
.content::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
/* ANIMATIONS */
@keyframes fadeUp {
from { opacity:0; transform:translateY(16px); }
to { opacity:1; transform:translateY(0); }
}
.stat-card { animation: fadeUp 0.5s ease both; }
.stat-card:nth-child(1) { animation-delay:0.05s; }
.stat-card:nth-child(2) { animation-delay:0.1s; }
.stat-card:nth-child(3) { animation-delay:0.15s; }
.stat-card:nth-child(4) { animation-delay:0.2s; }
.stat-card:nth-child(5) { animation-delay:0.25s; }
.card { animation: fadeUp 0.5s ease both; animation-delay:0.3s; }
/* === INLINE SPARKLINES === */
.sparkline { display:inline-block; vertical-align:middle; }
</style>
</head>
<body>
<div class="layout">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="logo">
<div class="logo-mark">
<div class="logo-icon">🌿</div>
<div>
<div class="logo-text">EnviroSense</div>
<div class="logo-sub">IoT Platform v2.4</div>
</div>
</div>
</div>
<nav>
<div class="nav-section">Monitor</div>
<div class="nav-item active"><span class="icon">⬡</span> Overview</div>
<div class="nav-item"><span class="icon">📡</span> Live Feed <span class="nav-badge">LIVE</span></div>
<div class="nav-item"><span class="icon">🗺</span> Device Map</div>
<div class="nav-section" style="margin-top:8px">Analyze</div>
<div class="nav-item"><span class="icon">📊</span> Analytics</div>
<div class="nav-item"><span class="icon">📈</span> Trends</div>
<div class="nav-item"><span class="icon">🔔</span> Alerts <span class="nav-badge">3</span></div>
<div class="nav-section" style="margin-top:8px">Manage</div>
<div class="nav-item"><span class="icon">⚙</span> Devices</div>
<div class="nav-item"><span class="icon">👥</span> Users</div>
<div class="nav-item"><span class="icon">📄</span> Reports</div>
<div class="nav-item"><span class="icon">🔧</span> Settings</div>
</nav>
<div class="sidebar-footer">
<div class="device-status">
<div class="status-dot"></div>
<span>24 / 26 ONLINE</span>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<!-- TOPBAR -->
<header class="topbar">
<div class="topbar-left">
<div class="page-title">Environmental Overview</div>
<div class="page-sub" id="live-sub">Realtime · Updated just now · Zone A–F</div>
</div>
<div class="topbar-right">
<div class="time-display" id="live-clock">--:--:--</div>
<button class="topbar-btn alert-btn">🔔 Alerts</button>
<button class="topbar-btn">⬇ Export</button>
<button class="topbar-btn" style="background:var(--accent);color:#080e12;border-color:var(--accent);">+ Add Device</button>
</div>
</header>
<!-- CONTENT -->
<div class="content">
<!-- STAT CARDS -->
<div class="stats-row">
<div class="stat-card" style="--card-color:#00ddb4">
<div class="stat-label">CO₂ Level <span class="stat-icon">💨</span></div>
<div class="stat-value" id="co2-val">412<span class="stat-unit">ppm</span></div>
<div class="stat-delta delta-ok">▲ +3 ppm from 1h ago · GOOD</div>
<div class="stat-bar"><div class="stat-bar-fill" id="co2-bar" style="width:41%"></div></div>
</div>
<div class="stat-card" style="--card-color:#00aaff">
<div class="stat-label">Temperature <span class="stat-icon">🌡</span></div>
<div class="stat-value" id="temp-val">23.4<span class="stat-unit">°C</span></div>
<div class="stat-delta delta-ok">▼ −0.3° from 1h ago · NORMAL</div>
<div class="stat-bar"><div class="stat-bar-fill" id="temp-bar" style="width:62%"></div></div>
</div>
<div class="stat-card" style="--card-color:#8b5cf6">
<div class="stat-label">Humidity <span class="stat-icon">💧</span></div>
<div class="stat-value" id="hum-val">58<span class="stat-unit">%</span></div>
<div class="stat-delta delta-ok">▼ −2% from 1h ago · OPTIMAL</div>
<div class="stat-bar"><div class="stat-bar-fill" id="hum-bar" style="width:58%"></div></div>
</div>
<div class="stat-card" style="--card-color:#ffb830">
<div class="stat-label">PM2.5 <span class="stat-icon">🌫</span></div>
<div class="stat-value" id="pm-val">28<span class="stat-unit">µg/m³</span></div>
<div class="stat-delta delta-up">▲ +4 from 1h ago · MODERATE</div>
<div class="stat-bar"><div class="stat-bar-fill" id="pm-bar" style="width:28%"></div></div>
</div>
<div class="stat-card" style="--card-color:#ff6b35">
<div class="stat-label">Noise Level <span class="stat-icon">🔊</span></div>
<div class="stat-value" id="noise-val">67<span class="stat-unit">dB</span></div>
<div class="stat-delta delta-up">▲ +5 dB from 1h ago · HIGH</div>
<div class="stat-bar"><div class="stat-bar-fill" id="noise-bar" style="width:67%"></div></div>
</div>
</div>
<!-- MAIN GRID -->
<div class="main-grid">
<!-- AIR QUALITY CHART -->
<div class="card">
<div class="card-header">
<div class="card-title"><span class="dot"></span> Air Quality — 24h Trend</div>
<div class="card-action">CO₂ · PM2.5 · VOC</div>
</div>
<div class="card-body chart-wrap">
<canvas id="airChart" height="180"></canvas>
</div>
</div>
<!-- TEMP/HUMIDITY CHART -->
<div class="card">
<div class="card-header">
<div class="card-title"><span class="dot" style="background:var(--accent2);box-shadow:0 0 6px var(--accent2)"></span> Temperature & Humidity</div>
<div class="card-action">Last 12h</div>
</div>
<div class="card-body chart-wrap">
<canvas id="thChart" height="180"></canvas>
</div>
</div>
<!-- ALERTS -->
<div class="card">
<div class="card-header">
<div class="card-title">⚠ Active Alerts</div>
<div class="card-action">View All →</div>
</div>
<div class="card-body">
<div class="alerts-list">
<div class="alert-item danger">
<div class="alert-icon">🔴</div>
<div class="alert-content">
<div class="alert-title">PM2.5 Threshold Exceeded</div>
<div class="alert-desc">Zone B · Device ENV-007<br>Current: 34 µg/m³ · Limit: 25</div>
</div>
<div class="alert-time">2m ago</div>
</div>
<div class="alert-item warn">
<div class="alert-icon">🟡</div>
<div class="alert-content">
<div class="alert-title">Noise Level Warning</div>
<div class="alert-desc">Zone D · Device ENV-012<br>Current: 78 dB · Limit: 70 dB</div>
</div>
<div class="alert-time">15m ago</div>
</div>
<div class="alert-item warn">
<div class="alert-icon">🟡</div>
<div class="alert-content">
<div class="alert-title">Device Battery Low</div>
<div class="alert-desc">Zone F · Device ENV-021<br>Battery at 8% — needs replacement</div>
</div>
<div class="alert-time">1h ago</div>
</div>
<div class="alert-item info">
<div class="alert-icon">🔵</div>
<div class="alert-content">
<div class="alert-title">Firmware Update Available</div>
<div class="alert-desc">6 devices pending update<br>v2.4.1 → v2.4.2</div>
</div>
<div class="alert-time">3h ago</div>
</div>
</div>
</div>
</div>
</div>
<!-- BOTTOM GRID -->
<div class="bottom-grid">
<!-- DEVICE TABLE -->
<div class="card">
<div class="card-header">
<div class="card-title">📡 Device Status</div>
<div class="card-action">All 26 Devices →</div>
</div>
<div class="card-body" style="padding:0 0 4px">
<table class="device-table">
<thead>
<tr>
<th style="padding-left:16px">Device</th>
<th>Zone</th>
<th>Status</th>
<th>Signal</th>
<th style="padding-right:16px">Last Ping</th>
</tr>
</thead>
<tbody id="device-tbody">
<!-- filled by JS -->
</tbody>
</table>
</div>
</div>
<!-- GAUGES + MAP -->
<div class="card">
<div class="card-header">
<div class="card-title">🗺 Sensor Zones</div>
<div class="card-action">Full Map →</div>
</div>
<div class="card-body">
<div class="device-map">
<div class="map-grid"></div>
<!-- Devices scattered across map -->
<div class="map-device" style="left:18%;top:25%">
<div class="map-device-dot"></div>
<div class="map-ping"></div>
<div class="map-label">Zone A</div>
</div>
<div class="map-device" style="left:40%;top:40%">
<div class="map-device-dot danger"></div>
<div class="map-ping"></div>
<div class="map-label">Zone B</div>
</div>
<div class="map-device" style="left:65%;top:22%">
<div class="map-device-dot"></div>
<div class="map-ping"></div>
<div class="map-label">Zone C</div>
</div>
<div class="map-device" style="left:78%;top:60%">
<div class="map-device-dot warn"></div>
<div class="map-ping"></div>
<div class="map-label">Zone D</div>
</div>
<div class="map-device" style="left:30%;top:72%">
<div class="map-device-dot"></div>
<div class="map-ping"></div>
<div class="map-label">Zone E</div>
</div>
<div class="map-device" style="left:55%;top:75%">
<div class="map-device-dot warn"></div>
<div class="map-ping"></div>
<div class="map-label">Zone F</div>
</div>
<div class="map-device" style="left:85%;top:30%">
<div class="map-device-dot"></div>
<div class="map-ping"></div>
<div class="map-label">Zone G</div>
</div>
</div>
<div class="map-legend" style="margin-top:10px">
<div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div>Normal</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--warn)"></div>Warning</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--danger)"></div>Critical</div>
</div>
<!-- Mini gauges below map -->
<div class="gauges-grid" style="margin-top:14px">
<div class="gauge-item">
<svg class="gauge-svg" width="70" height="40" viewBox="0 0 70 40">
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#1a2f2a" stroke-width="6" stroke-linecap="round"/>
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#00ddb4" stroke-width="6" stroke-linecap="round"
stroke-dasharray="94" stroke-dashoffset="50" opacity="0.9"/>
<text x="35" y="36" text-anchor="middle" font-family="'Syne',sans-serif" font-size="11" font-weight="800" fill="#e8f4f0">AQI</text>
</svg>
<div class="gauge-label">Air Quality</div>
<div class="gauge-val" style="color:var(--accent)">Good</div>
</div>
<div class="gauge-item">
<svg class="gauge-svg" width="70" height="40" viewBox="0 0 70 40">
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#1a2f2a" stroke-width="6" stroke-linecap="round"/>
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#ffb830" stroke-width="6" stroke-linecap="round"
stroke-dasharray="94" stroke-dashoffset="30" opacity="0.9"/>
<text x="35" y="36" text-anchor="middle" font-family="'Syne',sans-serif" font-size="11" font-weight="800" fill="#e8f4f0">73%</text>
</svg>
<div class="gauge-label">Uptime</div>
<div class="gauge-val" style="color:var(--warn)">73/h</div>
</div>
<div class="gauge-item">
<svg class="gauge-svg" width="70" height="40" viewBox="0 0 70 40">
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#1a2f2a" stroke-width="6" stroke-linecap="round"/>
<path d="M5,38 A30,30 0 0,1 65,38" fill="none" stroke="#00aaff" stroke-width="6" stroke-linecap="round"
stroke-dasharray="94" stroke-dashoffset="15" opacity="0.9"/>
<text x="35" y="36" text-anchor="middle" font-family="'Syne',sans-serif" font-size="11" font-weight="800" fill="#e8f4f0">92%</text>
</svg>
<div class="gauge-label">Connectivity</div>
<div class="gauge-val" style="color:var(--accent2)">92%</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /layout -->
<script>
// =====================
// CLOCK
// =====================
function updateClock() {
const now = new Date();
document.getElementById('live-clock').textContent =
now.toTimeString().slice(0,8);
}
updateClock();
setInterval(updateClock, 1000);
// =====================
// GENERATE CHART DATA
// =====================
function genData(len, base, amp, noiseAmp) {
return Array.from({length:len}, (_,i) => {
const t = i / len;
return +(base + Math.sin(t * Math.PI * 3) * amp + (Math.random()-0.5)*noiseAmp).toFixed(1);
});
}
const hours24 = Array.from({length:25}, (_,i) => {
const h = (new Date().getHours() - 24 + i + 24) % 24;
return h + ':00';
});
const hours12 = Array.from({length:13}, (_,i) => {
const h = (new Date().getHours() - 12 + i + 24) % 24;
return h + ':00';
});
// =====================
// CANVAS CHARTS
// =====================
function drawLineChart(canvasId, labels, datasets, opts={}) {
const canvas = document.getElementById(canvasId);
if(!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.parentElement.clientWidth - 36;
const H = opts.height || 180;
canvas.width = W;
canvas.height = H;
const pad = {top:16, right:16, bottom:28, left:40};
const pw = W - pad.left - pad.right;
const ph = H - pad.top - pad.bottom;
ctx.clearRect(0,0,W,H);
// Grid lines
ctx.strokeStyle = 'rgba(0,221,180,0.06)';
ctx.lineWidth = 1;
for(let i=0;i<=4;i++){
const y = pad.top + (ph/4)*i;
ctx.beginPath(); ctx.moveTo(pad.left,y); ctx.lineTo(W-pad.right,y); ctx.stroke();
}
// All datasets share same x-axis
const n = labels.length;
datasets.forEach(ds => {
const vals = ds.data;
const min = ds.min ?? Math.min(...vals)*0.9;
const max = ds.max ?? Math.max(...vals)*1.1;
const range = max - min || 1;
const pts = vals.map((v,i) => ({
x: pad.left + (i/(n-1))*pw,
y: pad.top + ph - ((v-min)/range)*ph
}));
// Fill gradient
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top+ph);
grad.addColorStop(0, ds.color + '30');
grad.addColorStop(1, ds.color + '00');
ctx.beginPath();
ctx.moveTo(pts[0].x, pad.top+ph);
pts.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(pts[pts.length-1].x, pad.top+ph);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for(let i=1;i<pts.length;i++){
const cp1x = (pts[i-1].x + pts[i].x)/2;
ctx.bezierCurveTo(cp1x, pts[i-1].y, cp1x, pts[i].y, pts[i].x, pts[i].y);
}
ctx.strokeStyle = ds.color;
ctx.lineWidth = 2;
ctx.stroke();
// Last dot
const last = pts[pts.length-1];
ctx.beginPath();
ctx.arc(last.x, last.y, 4, 0, Math.PI*2);
ctx.fillStyle = ds.color;
ctx.shadowColor = ds.color;
ctx.shadowBlur = 10;
ctx.fill();
ctx.shadowBlur = 0;
});
// X-axis labels (show every 4th)
ctx.fillStyle = 'rgba(90,122,112,0.8)';
ctx.font = '8px "Space Mono"';
ctx.textAlign = 'center';
labels.forEach((l,i) => {
if(i % 4 === 0) {
const x = pad.left + (i/(n-1))*pw;
ctx.fillText(l, x, H-6);
}
});
// Y-axis labels for first dataset
const ds0 = datasets[0];
const min0 = ds0.min ?? Math.min(...ds0.data)*0.9;
const max0 = ds0.max ?? Math.max(...ds0.data)*1.1;
ctx.textAlign = 'right';
for(let i=0;i<=4;i++){
const v = max0 - ((max0-min0)/4)*i;
ctx.fillText(v.toFixed(0), pad.left-6, pad.top + (ph/4)*i + 4);
}
// Legend
if(opts.legend !== false) {
let lx = pad.left;
datasets.forEach(ds => {
ctx.fillStyle = ds.color;
ctx.fillRect(lx, 3, 16, 3);
ctx.fillStyle = 'rgba(90,122,112,0.9)';
ctx.font = '8px "Space Mono"';
ctx.textAlign = 'left';
ctx.fillText(ds.label, lx+20, 9);
lx += ctx.measureText(ds.label).width + 36;
});
}
}
// Draw both charts
function drawCharts() {
drawLineChart('airChart', hours24, [
{ label:'CO₂ (ppm)', data: genData(25, 410, 30, 10), color:'#00ddb4', min:360, max:500 },
{ label:'PM2.5 (µg/m³)', data: genData(25, 22, 8, 4), color:'#ffb830', min:0, max:60 },
{ label:'VOC Index', data: genData(25, 100, 40, 15), color:'#8b5cf6', min:0, max:200 },
]);
drawLineChart('thChart', hours12, [
{ label:'Temp (°C)', data: genData(13, 23, 2, 0.5), color:'#00aaff', min:18, max:30 },
{ label:'Humidity (%)', data: genData(13, 58, 8, 3), color:'#8b5cf6', min:30, max:90 },
]);
}
drawCharts();
window.addEventListener('resize', drawCharts);
// =====================
// DEVICE TABLE
// =====================
const devices = [
{ name:'ENV-001', id:'A4:E2:3F', zone:'Zone A', status:'online', signal:4, ping:'2s' },
{ name:'ENV-007', id:'B1:F9:2A', zone:'Zone B', status:'warn', signal:3, ping:'4s' },
{ name:'ENV-012', id:'C3:D1:8E', zone:'Zone D', status:'warn', signal:2, ping:'8s' },
{ name:'ENV-015', id:'E2:A4:1C', zone:'Zone C', status:'online', signal:4, ping:'1s' },
{ name:'ENV-021', id:'F7:B2:9D', zone:'Zone F', status:'online', signal:1, ping:'12s' },
{ name:'ENV-026', id:'G5:C8:4B', zone:'Zone G', status:'online', signal:4, ping:'2s' },
];
function signalBars(level) {
let html = '<div class="signal-bars">';
for(let i=1;i<=4;i++){
html += `<div class="bar ${i<=level?'active':''}" style="height:${i*3+4}px"></div>`;
}
return html + '</div>';
}
const tbody = document.getElementById('device-tbody');
devices.forEach(d => {
const statusMap = {
online: '<span class="status-pill online">● Online</span>',
warn: '<span class="status-pill warn">● Warning</span>',
offline:'<span class="status-pill offline">● Offline</span>',
};
tbody.innerHTML += `
<tr>
<td style="padding-left:16px">
<div class="device-name">${d.name}</div>
<div class="device-id">${d.id}</div>
</td>
<td style="font-family:var(--font-mono);font-size:10px;color:var(--muted)">${d.zone}</td>
<td>${statusMap[d.status]}</td>
<td>${signalBars(d.signal)}</td>
<td style="font-family:var(--font-mono);font-size:10px;color:var(--muted);padding-right:16px">${d.ping} ago</td>
</tr>
`;
});
// =====================
// LIVE DATA SIMULATION
// =====================
const metrics = {
co2: { el:'co2-val', bar:'co2-bar', base:412, amp:8, unit:'ppm', suffix:'', max:1000 },
temp: { el:'temp-val', bar:'temp-bar', base:23.4, amp:0.4, unit:'°C', suffix:'', max:40 },
hum: { el:'hum-val', bar:'hum-bar', base:58, amp:3, unit:'%', suffix:'', max:100 },
pm: { el:'pm-val', bar:'pm-bar', base:28, amp:4, unit:'µg/m³', suffix:'', max:100 },
noise:{ el:'noise-val',bar:'noise-bar',base:67, amp:5, unit:'dB', suffix:'', max:100 },
};
setInterval(() => {
Object.values(metrics).forEach(m => {
const v = m.base + (Math.random()-0.5)*m.amp*2;
const disp = m.base < 5 ? v.toFixed(1) : Math.round(v);
const el = document.getElementById(m.el);
if(el) el.innerHTML = `${disp}<span class="stat-unit">${m.unit}</span>`;
const bar = document.getElementById(m.bar);
if(bar) bar.style.width = Math.min(100, Math.max(5, (v/m.max)*100)).toFixed(0) + '%';
});
// Redraw charts every 10s
}, 3000);
setInterval(drawCharts, 10000);
</script>
</body>
</html>5
3
104KB
110KB
295.0ms
492.0ms
403.0ms