style(portal): live-console location page, polished access splash, light/dark toggle

- Location page rebuilt as a monitoring console: Leq hero readout (mono, level-
  colored, auto-flips with theme), instrument strip for Lp/Lmax/L1/L10, refined
  dark Chart.js (mono ticks, thin lines), panel-styled alert history, polished
  pause overlay. All live-stream/chart/alert JS hooks preserved.
- Access page → centered branded splash.
- Light/Dark toggle: CSS-variable theme system (structure + level/metric accent
  colors flip), header sun/moon button, localStorage + no-flash boot script,
  smooth body transition. On toggle, a 'portal-theme' event re-skins the Chart.js
  trace and swaps Leaflet tiles (CARTO dark <-> light) + recolors map dots.

All JS hook IDs intact (verified); both themes validated to parse + balance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 20:10:29 +00:00
parent 4839d14a22
commit f760e81309
4 changed files with 205 additions and 107 deletions
+99 -60
View File
@@ -4,61 +4,74 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<a href="/portal" class="text-sm text-gray-400 hover:text-gray-200">&larr; All locations</a>
<h1 class="text-2xl font-semibold mt-1">{{ location.name }}</h1>
<div class="flex items-center gap-2 text-sm mt-1 mb-6">
<span id="p-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
<span id="p-fresh" class="text-gray-400"></span>
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
All locations
</a>
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
<div class="flex items-center gap-2.5">
<span id="p-badge" class="hidden"></span>
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
</div>
</div>
{% if not has_device %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
No device is currently assigned to this location.
</div>
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
{% else %}
<div id="p-alarm-banner" class="hidden mb-4 px-4 py-3 rounded-lg bg-red-900/30 border border-red-700/50 text-red-200 text-sm flex items-center gap-2">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(248,113,113,0.10)] border border-[rgba(248,113,113,0.38)] text-red-200 text-sm flex items-center gap-2.5">
<svg class="w-5 h-5 shrink-0 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
</svg>
<span id="p-alarm-text">Currently above threshold.</span>
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Lp (Instant)</div>
<div id="p-lp" class="text-2xl font-bold text-blue-400">--</div><div class="text-xs text-gray-500">dB</div>
<!-- Hero console: Leq primary + instrument strip -->
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
<div class="flex items-baseline gap-2.5">
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Leq (Average)</div>
<div id="p-leq" class="text-2xl font-bold text-green-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Lmax (Max)</div>
<div id="p-lmax" class="text-2xl font-bold text-red-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">L1</div>
<div id="p-ln1" class="text-2xl font-bold text-purple-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">L10</div>
<div id="p-ln2" class="text-2xl font-bold text-orange-400">--</div><div class="text-xs text-gray-500">dB</div>
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
<div>
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
</div>
</div>
</div>
<div class="relative rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
<canvas id="p-chart"></canvas>
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-slate-900/70 rounded-xl">
<button onclick="resumeStream()"
class="px-4 py-2 rounded-lg bg-seismo-orange/20 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/30 text-sm font-medium">
&#9208; Live paused — click to resume
</button>
<!-- Live trace -->
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
<canvas id="p-chart"></canvas>
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
<button onclick="resumeStream()"
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
&#9208; Live paused — tap to resume
</button>
</div>
</div>
</div>
<!-- Alert history (read-only) -->
<div class="mt-6">
<h2 class="text-sm font-medium text-gray-400 mb-2">Alert history</h2>
<!-- Alert history -->
<div class="reveal mt-7" style="animation-delay:180ms">
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert history</div>
<div id="p-events" class="space-y-2"></div>
</div>
{% endif %}
@@ -72,40 +85,65 @@ const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
let chart;
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
function ds(label, color) {
return { label, data: [], borderColor: color, backgroundColor: color,
borderWidth: 2, pointRadius: 0, tension: 0.3, spanGaps: true };
// Level color for the Leq hero (matches the overview bands).
const LEVEL_AMBER = 55, LEVEL_RED = 70;
function leqColor(measuring, v) {
// CSS var refs so the hero color auto-flips with the theme.
if (!measuring || v == null || isNaN(v)) return 'var(--text)';
if (v >= LEVEL_RED) return 'var(--lvl-bad)';
if (v >= LEVEL_AMBER) return 'var(--lvl-warn)';
return 'var(--lvl-ok)';
}
function paintLeq(measuring, leqVal) {
const el = document.getElementById('p-leq');
if (el) el.style.color = leqColor(measuring, parseFloat(leqVal));
}
function ds(label) { return { label, data: [], borderWidth: 1.5, pointRadius: 0, tension: 0.35, spanGaps: true }; }
function skinChart() {
if (!chart) return;
const dim = cssVar('--text-dim');
const cols = [cssVar('--m-lp'), cssVar('--lvl-ok'), cssVar('--m-l1'), cssVar('--m-l10')];
chart.data.datasets.forEach((d, i) => { d.borderColor = cols[i]; d.backgroundColor = cols[i]; });
const grid = 'rgba(124,146,188,0.10)', gridX = 'rgba(124,146,188,0.05)', border = 'rgba(124,146,188,0.18)';
const y = chart.options.scales.y, x = chart.options.scales.x;
y.ticks.color = dim; y.title.color = dim; y.grid.color = grid; y.border.color = border;
x.ticks.color = dim; x.grid.color = gridX; x.border.color = border;
chart.options.plugins.legend.labels.color = cssVar('--text');
chart.update('none');
}
function initChart() {
const ctx = document.getElementById('p-chart').getContext('2d');
const mono = { family: 'IBM Plex Mono', size: 10 };
chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [
ds('Lp', 'rgb(96,165,250)'), ds('Leq', 'rgb(74,222,128)'),
ds('L1', 'rgb(192,132,252)'), ds('L10', 'rgb(251,146,60)') ] },
data: { labels: [], datasets: [ds('Lp'), ds('Leq'), ds('L1'), ds('L10')] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { intersect: false, mode: 'index' },
scales: {
y: { min: 30, max: 130, title: { display: true, text: 'dB', color: '#94a3b8' },
ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
x: { ticks: { color: '#94a3b8', maxTicksLimit: 8 }, grid: { color: 'rgba(148,163,184,0.12)' } }
y: { min: 30, max: 130, title: { display: true, text: 'dB', font: { family: 'IBM Plex Mono' } },
ticks: { font: mono }, grid: {}, border: {} },
x: { ticks: { font: mono, maxTicksLimit: 8 }, grid: {}, border: {} }
},
plugins: { legend: { labels: { color: '#cbd5e1' } } }
plugins: { legend: { labels: { font: { family: 'Hanken Grotesk' }, usePointStyle: true, pointStyleWidth: 10, boxHeight: 7 } } }
}
});
skinChart();
}
document.addEventListener('portal-theme', skinChart);
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
function setBadge(measuring, lastSeen) {
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
if (measuring === null) { b.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full'; b.textContent = ''; }
else if (measuring) { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/40 text-green-300'; b.textContent = '● Live'; }
else { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-slate-700 text-gray-300'; b.textContent = '■ Stopped'; }
const base = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-full border ';
if (measuring === null) { b.className = 'hidden'; b.textContent = ''; }
else if (measuring) { b.className = base + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; b.innerHTML = '<span class="live-dot"></span> Live'; }
else { b.className = base + 'border-[var(--border)] text-[var(--text-dim)]'; b.textContent = 'Stopped'; }
f.innerHTML = fmtFreshness(lastSeen);
}
function fmtFreshness(iso) {
if (!iso) return '<span class="text-gray-500">no recent reading</span>';
if (!iso) return '<span class="text-[var(--text-dim)]">no recent reading</span>';
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
@@ -113,7 +151,7 @@ function fmtFreshness(iso) {
else if (s < 60) ago = s + 's ago';
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-400' : 'text-gray-400';
const cls = stale ? 'text-amber-400' : 'text-[var(--text-dim)]';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
}
@@ -131,6 +169,7 @@ async function prefill() {
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.last_seen);
paintLeq(measuring, d.leq);
} catch (e) { /* keep last values */ }
}
async function backfill() {
@@ -184,6 +223,7 @@ function openStream() {
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.timestamp || new Date().toISOString());
paintLeq(measuring, d.leq);
pushPoint(d);
};
ws.onclose = () => { ws = null; };
@@ -231,7 +271,7 @@ async function loadEvents() {
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
} else banner.classList.add('hidden');
const list = document.getElementById('p-events');
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-500">No alerts have fired.</div>'; return; }
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
list.innerHTML = '';
for (const e of events) {
const m = EV_METRIC[e.metric] || e.metric;
@@ -240,10 +280,9 @@ async function loadEvents() {
: `${fmtAlertTime(e.onset_at)}${fmtAlertTime(e.clear_at)}`;
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
const row = document.createElement('div');
row.className = 'px-3 py-2 rounded-lg border text-sm ' +
(active ? 'border-red-700/60 bg-red-900/20 text-red-200' : 'border-slate-700 bg-slate-800/40 text-gray-300');
row.innerHTML = `<div>${e.rule_name || 'Alert'} <span class="text-xs text-gray-400">· ${m} ${e.threshold_db} dB</span></div>
<div class="text-xs text-gray-400">${when}${peak}</div>`;
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(248,113,113,0.4)]' : '');
row.innerHTML = `<div class="${active ? 'text-red-300' : 'text-[var(--text)]'}">${e.rule_name || 'Alert'} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
list.appendChild(row);
}
} catch (e) { /* leave history as-is */ }