summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js552
-rw-r--r--web/index.html134
-rw-r--r--web/style.css214
3 files changed, 900 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..6b909a3
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,552 @@
+'use strict';
+
+const $ = (id) => document.getElementById(id);
+const HIST_MAX = 3600;
+
+/* ---------- constellation identity (fixed categorical order) ---------- */
+const CONSTS = [
+ { id: 0, name: 'GPS', v: '--s1' },
+ { id: 6, name: 'GLONASS', v: '--s2' },
+ { id: 2, name: 'Galileo', v: '--s3' },
+ { id: 3, name: 'BeiDou', v: '--s4' },
+ { id: 5, name: 'QZSS', v: '--s5' },
+ { id: 1, name: 'SBAS', v: '--s6' },
+ { id: 7, name: 'NavIC', v: '--s7' },
+ { id: 4, name: 'IMES', v: '--s8' },
+];
+const CONST_BY_ID = Object.fromEntries(CONSTS.map((c, i) => [c.id, { ...c, order: i }]));
+function gnssOf(sat) {
+ if (sat.gnssid != null) return sat.gnssid;
+ const p = sat.PRN; // NMEA PRN fallbacks
+ if (p <= 32) return 0;
+ if (p <= 64 || (p >= 120 && p <= 158)) return 1;
+ if (p <= 96) return 6;
+ if (p >= 193 && p <= 202) return 5;
+ if (p >= 201 && p <= 237) return 3;
+ if (p >= 301 && p <= 336) return 2;
+ return 0;
+}
+const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+
+/* ---------- formatting ---------- */
+function sig(v) {
+ const a = Math.abs(v);
+ if (a === 0) return '0';
+ if (a >= 100) return a.toFixed(0);
+ if (a >= 10) return a.toFixed(1);
+ return a.toFixed(2);
+}
+function fmtSec(s, signed = true) {
+ if (s == null || !isFinite(s)) return '—';
+ const a = Math.abs(s);
+ let v, u;
+ if (a < 1e-6) { v = s * 1e9; u = 'ns'; }
+ else if (a < 1e-3) { v = s * 1e6; u = 'µs'; }
+ else if (a < 1) { v = s * 1e3; u = 'ms'; }
+ else { v = s; u = 's'; }
+ const sign = signed ? (v < 0 ? '−' : '+') : '';
+ return `${sign}${sig(v)} ${u}`;
+}
+function fmtDur(sec) {
+ if (sec == null || sec < 0) return '—';
+ if (sec < 60) return `${Math.round(sec)} s`;
+ if (sec < 3600) return `${Math.round(sec / 60)} m`;
+ if (sec < 86400) return `${(sec / 3600).toFixed(1)} h`;
+ return `${(sec / 86400).toFixed(1)} d`;
+}
+const pad = (n, w = 2) => String(n).padStart(w, '0');
+const num = (v, d) => (v == null || !isFinite(v) ? '—' : v.toFixed(d));
+function fmtLat(v) { return v == null ? '—' : `${Math.abs(v).toFixed(4)}° ${v >= 0 ? 'N' : 'S'}`; }
+function fmtLon(v) { return v == null ? '—' : `${Math.abs(v).toFixed(4)}° ${v >= 0 ? 'E' : 'W'}`; }
+function utcHM(tSec) { const d = new Date(tSec * 1000); return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; }
+function utcHMS(tSec) { const d = new Date(tSec * 1000); return `${utcHM(tSec)}:${pad(d.getUTCSeconds())}`; }
+function setText(id, text) { const el = $(id); if (el && el.textContent !== text) el.textContent = text; }
+
+/* ---------- browser ↔ server clock offset ---------- */
+let clockOff = null; // ms to add to Date.now() to get server time
+let clockRtt = null;
+async function syncClock() {
+ let best = null;
+ for (let i = 0; i < 6; i++) {
+ try {
+ const t0 = Date.now();
+ const r = await fetch('api/time', { cache: 'no-store' });
+ const j = await r.json();
+ const t1 = Date.now();
+ const s = { rtt: t1 - t0, off: j.now * 1000 - (t0 + t1) / 2 };
+ if (!best || s.rtt < best.rtt) best = s;
+ } catch { /* ignore */ }
+ }
+ if (best) { clockOff = best.off; clockRtt = best.rtt; }
+}
+
+/* ---------- state ---------- */
+let latest = null;
+let history = [];
+let range = 600;
+let lastMsgAt = 0;
+let gpsLeap = 18; // GPS−UTC; replaced by receiver value if reported
+
+/* ---------- clock banner (animation frame) ---------- */
+let lastWholeSec = -1;
+function tickClock() {
+ const ms = Date.now() + (clockOff ?? 0);
+ const d = new Date(ms);
+ setText('utc-hms', `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`);
+ const whole = Math.floor(ms / 1000);
+ if (whole !== lastWholeSec) {
+ lastWholeSec = whole;
+ setText('utc-date', d.toLocaleDateString(undefined, { timeZone: 'UTC', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }));
+ setText('local-time', d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }) + ', ' +
+ d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
+ const jan1 = Date.UTC(d.getUTCFullYear(), 0, 1);
+ setText('doy', pad(Math.floor((ms - jan1) / 86400000) + 1, 3));
+ setText('mjd', (ms / 86400000 + 40587).toFixed(5));
+ setText('unix', String(whole));
+ const gps = whole - 315964800 + gpsLeap;
+ const wk = Math.floor(gps / 604800);
+ setText('gpswk', `${wk} / ${gps - wk * 604800}`);
+ if (clockOff != null) {
+ const o = -clockOff; // browser − server
+ const dir = Math.abs(o) < 0.5 ? 'in step' : o > 0 ? 'ahead' : 'behind';
+ setText('browser-off', `${Math.abs(o).toFixed(0)} ms ${dir} (±${Math.ceil(clockRtt / 2)} ms)`);
+ }
+ // stale stream detection
+ if (lastMsgAt && Date.now() - lastMsgAt > 5000) setConn('dead', 'no data — reconnecting');
+ }
+ requestAnimationFrame(tickClock);
+}
+
+function setConn(cls, text) {
+ $('conn-dot').className = `dot ${cls}`;
+ setText('conn-text', text);
+}
+
+/* ---------- health assessment ---------- */
+function assess(s) {
+ const reasons = [];
+ let level = 0; // 0 good, 1 warning, 2 critical
+ const bump = (l, r) => { level = Math.max(level, l); reasons.push(r); };
+ const t = s.chrony.tracking;
+ const src = s.chrony.sources || [];
+ if (!t) bump(2, 'chronyd is not responding');
+ else {
+ if (t.leap === 'Not synchronised' || t.stratum === 0 || t.stratum >= 16) bump(2, 'chronyd is not synchronised');
+ const sel = src.find((x) => x.state === '*');
+ if (!sel) bump(2, 'No source selected');
+ else if (sel.name !== s.ppsRefId) bump(1, `Using ${sel.name} instead of PPS`);
+ const a = Math.abs(t.systemOffset);
+ if (a > 1e-3) bump(2, `System offset ${fmtSec(t.systemOffset, false)}`);
+ else if (a > 100e-6) bump(1, `System offset ${fmtSec(t.systemOffset, false)}`);
+ const pps = src.find((x) => x.name === s.ppsRefId);
+ if (pps && (pps.reach & 0x7) !== 0x7) bump(1, 'Recent PPS samples missed');
+ }
+ const g = s.gps;
+ if (!g.connected) bump(1, 'gpsd unreachable');
+ else if (!g.tpv || g.tpv.mode < 2) bump(1, 'No GPS fix');
+ else if (g.tpvAge != null && g.tpvAge > 5) bump(1, `No GPS report for ${Math.round(g.tpvAge)} s`);
+ const text = level === 0 ? 'Locked to PPS' : level === 1 ? 'Degraded' : 'Not synchronised';
+ return { cls: ['good', 'warning', 'critical'][level], text, reasons };
+}
+
+/* ---------- render: chrony ---------- */
+function renderChrony(s) {
+ const c = s.chrony;
+ const t = c.tracking;
+ const err = $('chrony-err');
+ err.hidden = !c.error; err.textContent = c.error || '';
+ if (t) {
+ setText('t-sys', fmtSec(Math.abs(t.systemOffset), false));
+ setText('t-sys-dir', t.systemOffset >= 0 ? 'slow of NTP time' : 'fast of NTP time');
+ setText('t-rms', fmtSec(t.rmsOffset, false));
+ setText('t-ref', t.refName || t.refId);
+ setText('t-stratum', `stratum ${t.stratum} · refid ${t.refId}`);
+ setText('t-last', fmtSec(t.lastOffset));
+ setText('t-freq', `${Math.abs(t.freqPpm).toFixed(3)} ppm ${t.freqPpm >= 0 ? 'slow' : 'fast'}`);
+ setText('t-resid', `${t.residFreqPpm >= 0 ? '+' : '−'}${Math.abs(t.residFreqPpm).toFixed(3)} ppm`);
+ setText('t-skew', `${t.skewPpm.toFixed(3)} ppm`);
+ setText('t-rdelay', fmtSec(t.rootDelay, false));
+ setText('t-rdisp', fmtSec(t.rootDispersion, false));
+ setText('t-upd', `${t.updateInterval.toFixed(1)} s`);
+ setText('t-reftime', t.refTime ? `${utcHMS(t.refTime)} UTC (${fmtDur(s.now - t.refTime)} ago)` : '—');
+ setText('leap', t.leap);
+ }
+ document.querySelectorAll('.srv').forEach((el) => { el.hidden = !c.server; });
+ if (c.server) setText('t-srv', `${c.server.ratePerSec.toFixed(1)}/s · ${c.server.ntpReceived.toLocaleString()} total`);
+
+ const rows = (c.sources || []).map((x) => {
+ const type = x.mode === '#' ? 'refclock' : x.mode === '=' ? 'peer' : 'server';
+ let dots = '';
+ for (let b = 7; b >= 0; b--) dots += `<i class="${(x.reach >> b) & 1 ? 'on' : ''}"></i>`;
+ const stateTxt = x.state === '-' ? '−' : x.state;
+ return `<tr class="${x.state === '*' ? 'selected' : ''}">
+ <td class="state">${stateTxt}</td>
+ <td class="name-cell" title="${esc(x.name)}">${esc(x.name)}</td>
+ <td>${x.mode === '#' ? type : `${type === 'peer' ? 'peer' : 'NTP'} s${x.stratum}`}</td>
+ <td class="num">${fmtDur(2 ** x.poll)}</td>
+ <td><span class="reach" title="reach ${x.reach.toString(8).padStart(3, '0')} (oldest → newest)">${dots}</span></td>
+ <td class="num">${x.lastRx < 0 ? '—' : fmtDur(x.lastRx)}</td>
+ <td class="num mono">${fmtSec(x.offset)}<div class="sub2">±${fmtSec(x.error, false)}${x.stdDev != null ? ` · σ ${fmtSec(x.stdDev, false)}` : ''}</div></td>
+ </tr>`;
+ });
+ $('sources').innerHTML = rows.join('') || '<tr><td colspan="7" class="muted">No sources</td></tr>';
+}
+function esc(s) { return String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch])); }
+
+/* ---------- render: GPS ---------- */
+const FIX = ['Unknown', 'No fix', '2D', '3D'];
+const GSTATUS = ['', 'GPS', 'DGPS', 'RTK fixed', 'RTK float', 'Dead reckoning', 'GNSS + DR', 'Surveyed time', 'Simulated', 'P(Y)'];
+function renderGPS(s) {
+ const g = s.gps;
+ const err = $('gps-err');
+ err.hidden = g.connected; err.textContent = g.connected ? '' : `gpsd: ${g.error || 'not connected'}`;
+ const p = g.tpv || {};
+ setText('g-fix', g.tpv ? `${FIX[p.mode] ?? '—'}${p.mode >= 2 ? ' fix' : ''}` : '—');
+ setText('g-status', GSTATUS[p.status] || (g.tpv ? 'GPS' : ' '));
+ setText('g-used', g.uSat != null ? String(g.uSat) : '—');
+ setText('g-seen', g.nSat != null ? String(g.nSat) : '—');
+ setText('g-ept', p.ept != null ? `±${fmtSec(p.ept, false)}` : '—');
+ setText('g-lat', fmtLat(p.lat));
+ setText('g-lon', fmtLon(p.lon));
+ setText('g-alt', p.altMSL != null ? `${p.altMSL.toFixed(1)} m${p.altHAE != null ? ` (HAE ${p.altHAE.toFixed(1)} m)` : ''}` : '—');
+ setText('g-eph', p.eph != null ? `±${p.eph.toFixed(1)} m horiz${p.epv != null ? ` · ±${p.epv.toFixed(1)} m vert` : ''}` : '—');
+ const d = g.dop || {};
+ setText('g-dop', `${num(d.pdop, 2)} / ${num(d.hdop, 2)} / ${num(d.vdop, 2)} / ${num(d.tdop, 2)}`);
+ setText('g-toff', g.toff ? fmtSec(g.toff.latency, false) : '—');
+ setText('g-time', p.time ? `${p.time.replace('T', ' ').replace('Z', ' UTC')}${g.tpvAge != null && g.tpvAge > 3 ? ` (${Math.round(g.tpvAge)} s old)` : ''}` : '—');
+ const dev = g.device;
+ setText('g-dev', dev ? `${dev.driver || 'unknown'}${dev.subtype ? ' ' + dev.subtype.split(',')[0] : ''} on ${dev.path}${dev.bps ? ` @ ${dev.bps}` : ''}${g.version ? ` · gpsd ${g.version}` : ''}` : '—');
+ if (p.leapseconds) gpsLeap = p.leapseconds;
+ renderSky(g.satellites || []);
+}
+
+/* ---------- sky plot ---------- */
+function skyBase() {
+ let h = '';
+ for (const el of [0, 30, 60]) h += `<circle class="ring${el === 0 ? ' horizon' : ''}" r="${90 - el}"/>`;
+ for (let az = 0; az < 360; az += 45) {
+ const r = (az * Math.PI) / 180;
+ h += `<line class="spoke" x1="0" y1="0" x2="${(90 * Math.sin(r)).toFixed(1)}" y2="${(-90 * Math.cos(r)).toFixed(1)}"/>`;
+ }
+ h += `<text class="card-pt" x="0" y="-101">N</text><text class="card-pt" x="101" y="0">E</text><text class="card-pt" x="0" y="101">S</text><text class="card-pt" x="-101" y="0">W</text>`;
+ h += `<text class="el" x="2" y="-58">30°</text><text class="el" x="2" y="-28">60°</text>`;
+ return h + '<g id="sky-sats"></g>';
+}
+function sortSats(sats) {
+ return [...sats].sort((a, b) => {
+ const ca = CONST_BY_ID[gnssOf(a)]?.order ?? 99, cb = CONST_BY_ID[gnssOf(b)]?.order ?? 99;
+ return ca - cb || (a.svid ?? a.PRN) - (b.svid ?? b.PRN);
+ });
+}
+function satLabel(x) {
+ const c = CONST_BY_ID[gnssOf(x)];
+ return `${c ? c.name : 'Sat'} ${x.svid ?? x.PRN}`;
+}
+function renderSky(sats) {
+ const sorted = sortSats(sats);
+ // sky
+ let h = '';
+ // draw unused first so used sit on top
+ for (const x of [...sorted].sort((a, b) => a.used - b.used)) {
+ if (x.az == null || x.el == null || x.el < 0) continue;
+ const c = CONST_BY_ID[gnssOf(x)] || CONSTS[0];
+ const r = (90 * (90 - x.el)) / 90, a = (x.az * Math.PI) / 180;
+ const cx = (r * Math.sin(a)).toFixed(1), cy = (-r * Math.cos(a)).toFixed(1);
+ const col = `var(${c.v})`;
+ const style = x.used ? `fill:${col}` : `stroke:${col}`;
+ h += `<circle class="sat${x.used ? '' : ' hollow'}" cx="${cx}" cy="${cy}" r="${x.used ? 6 : 5}" style="${style}" data-tip="${esc(satTip(x))}"/>`;
+ h += `<text class="prn" x="${cx}" y="${(+cy + 14).toFixed(1)}">${x.svid ?? x.PRN}</text>`;
+ }
+ const g = $('sky-sats');
+ if (g) g.innerHTML = h;
+
+ // constellation table
+ const counts = new Map();
+ for (const x of sorted) {
+ const id = gnssOf(x);
+ const e = counts.get(id) || { used: 0, seen: 0 };
+ e.seen++; if (x.used) e.used++;
+ counts.set(id, e);
+ }
+ $('const').innerHTML = [...counts.entries()]
+ .sort((a, b) => (CONST_BY_ID[a[0]]?.order ?? 99) - (CONST_BY_ID[b[0]]?.order ?? 99))
+ .map(([id, e]) => {
+ const c = CONST_BY_ID[id] || { name: `gnss ${id}`, v: '--muted' };
+ return `<tr><td><i style="background:var(${c.v})"></i>${c.name}</td><td class="num">${e.used}</td><td class="num">${e.seen}</td></tr>`;
+ }).join('') || '<tr><td colspan="3" class="muted">No satellites</td></tr>';
+
+ // SNR bars
+ const MAX = 50;
+ $('snr').innerHTML = sorted.map((x) => {
+ const c = CONST_BY_ID[gnssOf(x)] || CONSTS[0];
+ const ss = x.ss ?? 0;
+ const hPct = Math.max(0, Math.min(1, ss / MAX)) * 100;
+ return `<div class="col${x.used ? '' : ' unused'}" data-tip="${esc(satTip(x))}">
+ <span class="v">${ss ? Math.round(ss) : ''}</span>
+ <div class="track"><div class="fill" style="height:${hPct}%;background:var(${c.v})"></div></div>
+ <span class="lab">${x.svid ?? x.PRN}</span></div>`;
+ }).join('');
+}
+function satTip(x) {
+ return `${satLabel(x)}${x.svid != null && x.svid !== x.PRN ? ` (PRN ${x.PRN})` : ''}|Signal|${x.ss != null ? x.ss.toFixed(0) + ' dB-Hz' : '—'}|Elevation|${x.el != null ? x.el.toFixed(0) + '°' : '—'}|Azimuth|${x.az != null ? x.az.toFixed(0) + '°' : '—'}|Status|${x.used ? 'used in fix' : 'tracked only'}`;
+}
+
+/* ---------- tooltip ---------- */
+const tip = $('tip');
+function showTip(html, clientX, clientY) {
+ tip.innerHTML = html;
+ tip.hidden = false;
+ const w = tip.offsetWidth, h = tip.offsetHeight;
+ let x = clientX + 14, y = clientY + 14;
+ if (x + w > window.innerWidth - 8) x = clientX - w - 14;
+ if (y + h > window.innerHeight - 8) y = clientY - h - 14;
+ tip.style.left = `${Math.max(8, x)}px`;
+ tip.style.top = `${Math.max(8, y)}px`;
+}
+function hideTip() { tip.hidden = true; }
+document.addEventListener('pointerover', (e) => {
+ const el = e.target.closest?.('[data-tip]');
+ if (!el) return;
+ const parts = el.dataset.tip.split('|');
+ let h = `<div class="t">${esc(parts[0])}</div>`;
+ for (let i = 1; i + 1 < parts.length; i += 2) h += `<div class="row">${esc(parts[i])}<b>${esc(parts[i + 1])}</b></div>`;
+ showTip(h, e.clientX, e.clientY);
+});
+document.addEventListener('pointermove', (e) => {
+ if (!tip.hidden && e.target.closest?.('[data-tip]')) showTip(tip.innerHTML, e.clientX, e.clientY);
+});
+document.addEventListener('pointerout', (e) => { if (e.target.closest?.('[data-tip]')) hideTip(); });
+
+/* ---------- line charts (canvas) ---------- */
+class LineChart {
+ constructor(fig, series, fmtY, fmtTip) {
+ this.fig = fig; this.series = series; this.fmtY = fmtY; this.fmtTip = fmtTip || fmtY;
+ this.canvas = document.createElement('canvas');
+ fig.appendChild(this.canvas);
+ if (series.length > 1) {
+ fig.querySelector('figcaption').innerHTML += series.map((s) => ` <span class="lg"><i style="background:var(${s.v})"></i>${s.label}</span>`).join('');
+ }
+ this.hoverX = null;
+ this.canvas.addEventListener('pointermove', (e) => { this.hoverX = e.offsetX; this.draw(); this.tip(e); });
+ this.canvas.addEventListener('pointerleave', () => { this.hoverX = null; hideTip(); this.draw(); });
+ }
+ draw() {
+ const cv = this.canvas, dpr = window.devicePixelRatio || 1;
+ const W = cv.clientWidth, H = cv.clientHeight;
+ if (!W || !H) return;
+ if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); }
+ const ctx = cv.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, W, H);
+
+ const tMax = history.length ? history[history.length - 1].t : Date.now() / 1000;
+ const tMin = tMax - range;
+ const pts = history.filter((p) => p.t >= tMin);
+ let lo = Infinity, hi = -Infinity;
+ for (const p of pts) for (const s of this.series) { const v = p[s.key]; if (v != null) { lo = Math.min(lo, v); hi = Math.max(hi, v); } }
+ if (!isFinite(lo)) { lo = 0; hi = 1; }
+ if (this.series[0].zero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
+ if (hi - lo < 1e-12) { const d = Math.abs(hi) * 0.1 || 1e-9; lo -= d; hi += d; }
+ const ticks = niceTicks(lo, hi, 4);
+ lo = Math.min(lo, ticks[0]); hi = Math.max(hi, ticks[ticks.length - 1]);
+
+ ctx.font = '11px system-ui, -apple-system, "Segoe UI", sans-serif';
+ const labels = ticks.map((v) => this.fmtY(v));
+ const padL = Math.ceil(Math.max(...labels.map((l) => ctx.measureText(l).width))) + 8;
+ const padR = 6, padT = 6, padB = 18;
+ const pw = W - padL - padR, ph = H - padT - padB;
+ const x = (t) => padL + ((t - tMin) / range) * pw;
+ const y = (v) => padT + (1 - (v - lo) / (hi - lo)) * ph;
+ this.geom = { x, y, tMin, padL, pw, pts };
+
+ // grid + y labels
+ ctx.fillStyle = css('--muted'); ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
+ ticks.forEach((v, i) => {
+ const yy = Math.round(y(v)) + 0.5;
+ ctx.strokeStyle = v === 0 ? css('--axis') : css('--grid'); ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(W - padR, yy); ctx.stroke();
+ ctx.fillText(labels[i], padL - 6, yy);
+ });
+ // x labels
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
+ const step = range <= 600 ? 120 : range <= 1800 ? 600 : 900;
+ for (let t = Math.ceil(tMin / step) * step; t <= tMax; t += step) {
+ const xx = x(t);
+ if (xx < padL + 14 || xx > W - padR - 14) continue;
+ ctx.fillText(utcHM(t), xx, H - padB + 4);
+ }
+ // lines
+ for (const s of this.series) {
+ ctx.strokeStyle = css(s.v); ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
+ ctx.beginPath();
+ let pen = false, prevT = null;
+ for (const p of pts) {
+ const v = p[s.key];
+ if (v == null || (prevT != null && p.t - prevT > 5)) pen = false;
+ if (v != null) { if (pen) ctx.lineTo(x(p.t), y(v)); else ctx.moveTo(x(p.t), y(v)); pen = true; }
+ prevT = p.t;
+ }
+ ctx.stroke();
+ }
+ // crosshair
+ const hp = this.hoverPoint();
+ if (hp) {
+ const xx = Math.round(x(hp.t)) + 0.5;
+ ctx.strokeStyle = css('--axis'); ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(xx, padT); ctx.lineTo(xx, padT + ph); ctx.stroke();
+ for (const s of this.series) {
+ const v = hp[s.key]; if (v == null) continue;
+ ctx.fillStyle = css(s.v); ctx.strokeStyle = css('--surface'); ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(x(hp.t), y(v), 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
+ }
+ }
+ }
+ hoverPoint() {
+ if (this.hoverX == null || !this.geom || !this.geom.pts.length) return null;
+ const { tMin, padL, pw, pts } = this.geom;
+ const t = tMin + ((this.hoverX - padL) / pw) * range;
+ let best = null, bd = Infinity;
+ for (const p of pts) { const d = Math.abs(p.t - t); if (d < bd) { bd = d; best = p; } }
+ return bd <= range / 50 + 2 ? best : null;
+ }
+ tip(e) {
+ const hp = this.hoverPoint();
+ if (!hp) { hideTip(); return; }
+ let h = `<div class="t">${utcHMS(hp.t)} UTC</div>`;
+ for (const s of this.series) h += `<div class="row"><i style="background:var(${s.v})"></i>${s.label}<b>${hp[s.key] == null ? '—' : this.fmtTip(hp[s.key])}</b></div>`;
+ showTip(h, e.clientX, e.clientY);
+ }
+}
+function niceTicks(lo, hi, n) {
+ const span = hi - lo;
+ const raw = span / n;
+ const mag = 10 ** Math.floor(Math.log10(raw));
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= n) || 10 * mag;
+ const out = [];
+ for (let v = Math.floor(lo / step) * step; v <= hi + step * 0.5; v += step) out.push(Math.abs(v) < step * 1e-6 ? 0 : v);
+ return out;
+}
+
+const charts = [
+ new LineChart($('ch-offset'), [
+ { key: 'sys', label: 'System (+ = slow)', v: '--s1', zero: true },
+ { key: 'pps', label: 'PPS sample', v: '--s2' },
+ ], (v) => fmtSec(v).replace('+', '')),
+ new LineChart($('ch-freq'), [{ key: 'freq', label: 'Frequency', v: '--s1' }], (v) => v.toFixed(3)),
+ new LineChart($('ch-sats'), [
+ { key: 'seen', label: 'In view', v: '--s1' },
+ { key: 'used', label: 'Used', v: '--s2' },
+ ], (v) => String(Math.round(v * 10) / 10), (v) => String(v)),
+];
+function drawCharts() { if (!document.hidden) charts.forEach((c) => c.draw()); }
+window.addEventListener('resize', drawCharts);
+document.querySelectorAll('.range button').forEach((b) => b.addEventListener('click', () => {
+ range = +b.dataset.range;
+ document.querySelectorAll('.range button').forEach((o) => o.classList.toggle('on', o === b));
+ drawCharts();
+}));
+
+/* ---------- main update ---------- */
+function onSnapshot(s) {
+ latest = s;
+ lastMsgAt = Date.now();
+ setConn('live', 'live');
+ if (clockOff == null) clockOff = s.now * 1000 - Date.now();
+ setText('host', s.host);
+ setText('ver', s.version);
+ document.title = `${s.host} · GPS Clock`;
+
+ const a = assess(s);
+ $('status').className = `status ${a.cls}`;
+ setText('status-text', a.text);
+ $('status-reasons').innerHTML = a.reasons.map((r) => `<li>${esc(r)}</li>`).join('');
+
+ renderChrony(s);
+ renderGPS(s);
+
+ if (s.point && (!history.length || s.point.t > history[history.length - 1].t)) {
+ history.push(s.point);
+ if (history.length > HIST_MAX) history.splice(0, history.length - HIST_MAX);
+ }
+ drawCharts();
+}
+
+function connect() {
+ const es = new EventSource('events');
+ es.onmessage = (e) => { try { onSnapshot(JSON.parse(e.data)); } catch (err) { console.error(err); } };
+ es.onerror = () => setConn('dead', 'disconnected — retrying');
+}
+
+/* ---------- theme toggle ---------- */
+(function theme() {
+ const root = document.documentElement;
+ try { const t = localStorage.getItem('theme'); if (t) root.dataset.theme = t; } catch { /* ignore */ }
+ $('theme').addEventListener('click', () => {
+ const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
+ root.dataset.theme = dark ? 'light' : 'dark';
+ try { localStorage.setItem('theme', root.dataset.theme); } catch { /* ignore */ }
+ drawCharts();
+ });
+ matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', drawCharts);
+})();
+
+/* ---------- hover help ---------- */
+const HELP = {
+ 'utc-hms': 'Current UTC from this server, kept in step with the server clock (not your browser clock).',
+ 'local-time': 'The same instant in this browser\'s local time zone.',
+ doy: 'Day of the year in UTC (001–366). Common in logs and scientific timestamps.',
+ mjd: 'Modified Julian Date: days since 1858-11-17 00:00 UTC, with a fractional day. Used in astronomy and timing labs.',
+ unix: 'Seconds since 1970-01-01 00:00 UTC, not counting leap seconds. This is what most computers store.',
+ gpswk: 'GPS week number and seconds into that week (Time of Week). GPS time ignores leap seconds, so it is currently ahead of UTC by the GPS−UTC leap offset.',
+ leap: 'chronyd\'s leap-second state. "Normal" is expected. "Insert/Delete second" means a leap second is scheduled for the end of the day. "Not synchronised" means chronyd has no usable reference.',
+ 'browser-off': 'How far this browser\'s clock differs from the server, measured over HTTP. ± is the uncertainty from network round-trip time. This affects only this page, not the server.',
+ status: 'Overall health. "Locked to PPS" means chronyd is steering to the PPS reference with small offset and the GPS has a fix. "Degraded" means it still works but something needs attention (reasons listed below). "Not synchronised" means the clock is not being disciplined.',
+ 't-sys': 'How far chronyd estimates the system clock is from true time right now. It is slewing the clock to remove this. For a PPS-disciplined server this should be well under 10 µs.',
+ 't-rms': 'Long-term average of measured offsets (root mean square). Shows how tightly the clock is held over time, independent of the current moment.',
+ 't-ref': 'The source chronyd is currently synchronised to and its stratum. Stratum 1 means directly attached to a reference clock such as GPS/PPS. Refid is the short identifier other NTP clients see.',
+ 't-last': 'Offset measured on the most recent clock update. Shows how much the clock needed correcting at that moment.',
+ 't-freq': 'How fast or slow the system\'s crystal oscillator would run without correction, in parts per million. 1 ppm ≈ 86 ms per day. It changes slowly with temperature.',
+ 't-resid': 'Difference between the frequency the reference suggests and the one in use. Near zero means chronyd\'s frequency estimate is accurate.',
+ 't-skew': 'Estimated error bound on the frequency. Smaller means chronyd is more confident about the oscillator\'s rate.',
+ 't-rdelay': 'Total network round-trip delay back to the stratum-1 reference. For a local PPS refclock this is essentially zero.',
+ 't-rdisp': 'Total accumulated uncertainty back to the reference. Together with root delay it bounds how wrong the clock could be (max error ≈ dispersion + delay/2). Clients use it to pick the best server.',
+ 't-upd': 'Seconds between the last two clock corrections. Tracks the reference\'s polling interval (about 16 s for a typical PPS refclock). A much longer value suggests missed samples or a fallback to slower NTP sources.',
+ 't-reftime': 'When chronyd last took a measurement from the reference. This should always be recent (within about one update interval). If it falls behind, chronyd is running on its own oscillator without fresh data.',
+ 't-srv': 'NTP requests this server is answering for clients: current rate and total since chronyd started.',
+ 'g-fix': 'GPS position fix. A 3D fix (4+ satellites) is best. Timing works with a 2D fix, and a surveyed-in timing receiver may keep good time with fewer. "No fix" means PPS output may be unreliable or absent.',
+ 'g-used': 'Satellites used in the fix, out of those the receiver is tracking. More used satellites give better timing stability; at least 4 is needed for a 3D fix.',
+ 'g-ept': 'The receiver\'s estimate of the error in its reported time. This applies to the NMEA serial time, which is only coarse. PPS supplies the precise second edge.',
+ 'g-lat': 'Antenna latitude reported by the receiver.',
+ 'g-lon': 'Antenna longitude reported by the receiver.',
+ 'g-alt': 'Antenna altitude above mean sea level (MSL). HAE is height above the WGS-84 ellipsoid, which GPS uses internally.',
+ 'g-eph': 'Estimated 95% position uncertainty, horizontal and vertical. Large values mean a weak or poorly spread set of satellites.',
+ 'g-dop': 'Dilution of precision: how satellite geometry amplifies errors in Position, Horizontal, Vertical and Time. Lower is better; under 2 is excellent and over 5 is poor. TDOP matters most for timing.',
+ 'g-toff': 'Delay from the top of the second to when the NMEA time message arrives over serial (gpsd TOFF). Useful for tuning the offset of an NMEA refclock; it does not affect PPS.',
+ 'g-time': 'UTC time in the receiver\'s latest fix report. Flags how old it is if reports stop arriving.',
+ 'g-dev': 'gpsd driver, device path and serial speed of the receiver, and the gpsd version.',
+};
+function applyHelp() {
+ for (const [id, text] of Object.entries(HELP)) {
+ const el = $(id);
+ if (!el) continue;
+ const tile = el.closest('.tile');
+ if (tile && id !== 'status') { tile.title = text; continue; }
+ el.title = text;
+ if (el.tagName === 'DD' && el.previousElementSibling?.tagName === 'DT') el.previousElementSibling.title = text;
+ }
+}
+
+/* ---------- boot ---------- */
+applyHelp();
+$('sky').innerHTML = skyBase();
+requestAnimationFrame(tickClock);
+Promise.all([
+ fetch('api/history', { cache: 'no-store' }).then((r) => r.json()).catch(() => null),
+ fetch('api/status', { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
+]).then(([h, s]) => {
+ if (Array.isArray(h)) history = h;
+ if (s && !latest) onSnapshot(s); else drawCharts();
+}).finally(connect);
+syncClock();
+setInterval(syncClock, 5 * 60 * 1000);
+document.addEventListener('visibilitychange', drawCharts);
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..f4d8ec8
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,134 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>GPS Clock</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='13' fill='none' stroke='%232a78d6' stroke-width='3'/%3E%3Cpath d='M16 8v8l5 4' stroke='%232a78d6' stroke-width='3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+<header class="top">
+ <div class="brand"><span id="host">—</span> <span class="muted">GPS + PPS time server</span></div>
+ <div class="conn" title="Live data stream from the server. If it stops, the page reconnects automatically."><span id="conn-dot" class="dot"></span><span id="conn-text">connecting…</span>
+ <button id="theme" type="button" title="Toggle theme" aria-label="Toggle theme">◐</button></div>
+</header>
+
+<main>
+ <!-- Clock banner -->
+ <section class="card clock">
+ <div class="clock-main">
+ <div class="label">UTC</div>
+ <div class="utc"><span id="utc-hms">--:--:--</span></div>
+ <div id="utc-date" class="date">—</div>
+ </div>
+ <div class="clock-side">
+ <dl class="kv compact">
+ <dt>Local</dt><dd id="local-time">—</dd>
+ <dt>Day of year</dt><dd id="doy">—</dd>
+ <dt>MJD</dt><dd id="mjd">—</dd>
+ <dt>Unix</dt><dd id="unix">—</dd>
+ <dt>GPS week / TOW</dt><dd id="gpswk">—</dd>
+ <dt>Leap status</dt><dd id="leap">—</dd>
+ <dt>Browser clock</dt><dd id="browser-off">—</dd>
+ </dl>
+ </div>
+ </section>
+
+ <div class="grid two">
+ <!-- Time quality -->
+ <section class="card">
+ <h2>Time quality <span class="sub">chronyc tracking</span></h2>
+ <div class="tiles">
+ <div class="tile"><div class="tile-label">System offset</div><div id="t-sys" class="tile-value">—</div><div id="t-sys-dir" class="tile-sub">&nbsp;</div></div>
+ <div class="tile"><div class="tile-label">RMS offset</div><div id="t-rms" class="tile-value">—</div><div class="tile-sub">long-term avg</div></div>
+ <div class="tile"><div class="tile-label">Reference</div><div id="t-ref" class="tile-value">—</div><div id="t-stratum" class="tile-sub">&nbsp;</div>
+ <div id="status" class="status unknown"><span class="status-icon" aria-hidden="true">●</span><span id="status-text">Waiting for data</span></div></div>
+ </div>
+ <ul id="status-reasons" class="reasons"></ul>
+ <dl class="kv">
+ <dt>Last offset</dt><dd id="t-last">—</dd>
+ <dt>Frequency</dt><dd id="t-freq">—</dd>
+ <dt>Residual freq</dt><dd id="t-resid">—</dd>
+ <dt>Skew</dt><dd id="t-skew">—</dd>
+ <dt>Root delay</dt><dd id="t-rdelay">—</dd>
+ <dt>Root dispersion</dt><dd id="t-rdisp">—</dd>
+ <dt>Update interval</dt><dd id="t-upd">—</dd>
+ <dt>Ref time</dt><dd id="t-reftime">—</dd>
+ <dt class="srv">NTP requests</dt><dd class="srv" id="t-srv">—</dd>
+ </dl>
+ <p id="chrony-err" class="err" hidden></p>
+ </section>
+
+ <!-- Sources -->
+ <section class="card">
+ <h2>Sources <span class="sub">chronyc sources / sourcestats</span></h2>
+ <div class="table-wrap">
+ <table class="sources">
+ <thead><tr><th title="Selection state: how chronyd is using this source (see legend below). * is the source the clock is steered to.">S</th><th title="Source name: a reference clock driver (e.g. GPS/PPS) or an NTP server hostname/address.">Name</th><th title="refclock = hardware attached to this machine; NTP sN = network server at stratum N (lower is closer to a reference clock).">Type</th><th class="num" title="Polling interval: how often chronyd takes a sample from this source.">Poll</th><th title="Reachability of the last 8 polls, oldest to newest. Filled = sample received. Gaps mean missed samples; all empty means the source is unreachable.">Reach</th><th class="num" title="How long ago the last good sample from this source arrived.">Last</th><th class="num" title="Offset of this source relative to the local clock in its last sample (+ = local clock is slow). ± is the error bound of that measurement; σ is the standard deviation of recent samples. Smaller and steadier is better.">Offset</th></tr></thead>
+ <tbody id="sources"></tbody>
+ </table>
+ </div>
+ <p class="legend-note"><b>*</b> selected · <b>+</b> combined · <b>−</b> not combined · <b>x</b> falseticker · <b>~</b> too variable · <b>?</b> unusable</p>
+ </section>
+ </div>
+
+ <!-- History -->
+ <section class="card">
+ <h2>History
+ <span class="range" role="group" aria-label="Time range">
+ <button type="button" data-range="600" class="on">10 min</button><button type="button" data-range="1800">30 min</button><button type="button" data-range="3600">60 min</button>
+ </span>
+ </h2>
+ <div class="grid three charts">
+ <figure class="chart" id="ch-offset"><figcaption title="System offset is how far chronyd estimates the system clock is from true time; PPS sample is the raw offset measured on each pulse-per-second edge. Both should hover near zero. Spikes or drift point to PPS trouble or heavy system load.">Offset</figcaption></figure>
+ <figure class="chart" id="ch-freq"><figcaption title="The correction chronyd applies to the crystal oscillator's rate, in parts per million. It drifts slowly with temperature; sudden jumps suggest a problem.">Frequency (ppm)</figcaption></figure>
+ <figure class="chart" id="ch-sats"><figcaption title="Satellites in view and used in the fix over time. A drop to zero means the antenna has lost sky view and PPS may become unreliable.">Satellites</figcaption></figure>
+ </div>
+ </section>
+
+ <div class="grid two">
+ <!-- GPS -->
+ <section class="card">
+ <h2>GPS receiver <span class="sub">gpsd</span></h2>
+ <div class="tiles">
+ <div class="tile"><div class="tile-label">Fix</div><div id="g-fix" class="tile-value">—</div><div id="g-status" class="tile-sub">&nbsp;</div></div>
+ <div class="tile"><div class="tile-label">Satellites</div><div class="tile-value"><span id="g-used">—</span><span class="of"> / <span id="g-seen">—</span></span></div><div class="tile-sub">used / in view</div></div>
+ <div class="tile"><div class="tile-label">Time error (est.)</div><div id="g-ept" class="tile-value">—</div><div class="tile-sub">NMEA, before PPS</div></div>
+ </div>
+ <dl class="kv">
+ <dt>Latitude</dt><dd id="g-lat">—</dd>
+ <dt>Longitude</dt><dd id="g-lon">—</dd>
+ <dt>Altitude (MSL)</dt><dd id="g-alt">—</dd>
+ <dt>Position error</dt><dd id="g-eph">—</dd>
+ <dt>DOP (P / H / V / T)</dt><dd id="g-dop">—</dd>
+ <dt>Serial latency</dt><dd id="g-toff">—</dd>
+ <dt>GPS fix time</dt><dd id="g-time">—</dd>
+ <dt>Receiver</dt><dd id="g-dev">—</dd>
+ </dl>
+ <p id="gps-err" class="err" hidden></p>
+ </section>
+
+ <!-- Satellites -->
+ <section class="card">
+ <h2>Satellites <span class="sub">sky view &amp; signal</span></h2>
+ <div class="sky-wrap">
+ <svg id="sky" viewBox="-110 -110 220 220" role="img" aria-label="Sky plot of satellites by azimuth and elevation"></svg>
+ <div class="sky-side">
+ <table class="const">
+ <thead><tr><th title="Satellite system: GPS (USA), GLONASS (Russia), Galileo (EU), BeiDou (China), QZSS (Japan), SBAS (augmentation).">Constellation</th><th class="num" title="Satellites from this system used in the current fix.">Used</th><th class="num" title="Satellites from this system the receiver is tracking.">Seen</th></tr></thead>
+ <tbody id="const"></tbody>
+ </table>
+ <p class="legend-note"><span class="sw filled"></span> used in fix &nbsp; <span class="sw hollow"></span> tracked only</p>
+ </div>
+ </div>
+ <div class="snr-head"><span title="Carrier-to-noise density per satellite. Above about 35 dB-Hz is good, 25–35 is usable, below 25 is weak. Consistently low values suggest antenna, cable or placement problems.">Signal strength (dB-Hz)</span></div>
+ <div id="snr" class="snr" role="img" aria-label="Signal strength per satellite"></div>
+ </section>
+ </div>
+</main>
+<div id="tip" class="tip" hidden></div>
+<footer class="foot muted">gps-clock-dash <span id="ver"></span> · updates every second</footer>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..15ced4b
--- /dev/null
+++ b/web/style.css
@@ -0,0 +1,214 @@
+:root {
+ color-scheme: light;
+ --page: #f9f9f7;
+ --surface: #fcfcfb;
+ --text: #0b0b0b;
+ --text-2: #52514e;
+ --muted: #898781;
+ --grid: #e1e0d9;
+ --axis: #c3c2b7;
+ --border: rgba(11, 11, 11, 0.10);
+ --wash: rgba(11, 11, 11, 0.04);
+
+ /* categorical (fixed order) */
+ --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100;
+ --s5: #e87ba4; --s6: #008300; --s7: #6250d6; --s8: #e34948;
+
+ /* status (reserved) */
+ --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
+ --good-text: #006300;
+
+ --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;
+ --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ color-scheme: dark;
+ --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500;
+ --s5: #d55181; --s6: #008300; --s7: #9085e9; --s8: #e66767;
+ --good-text: #0ca30c;
+ }
+}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500;
+ --s5: #d55181; --s6: #008300; --s7: #9085e9; --s8: #e66767;
+ --good-text: #0ca30c;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; }
+body {
+ background: var(--page);
+ color: var(--text);
+ font: 14px/1.45 var(--sans);
+ -webkit-font-smoothing: antialiased;
+}
+.muted { color: var(--muted); }
+main { max-width: 1280px; margin: 0 auto; padding: 0 16px 16px; display: grid; gap: 16px; }
+
+.top {
+ max-width: 1280px; margin: 0 auto; padding: 14px 16px;
+ display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
+}
+.brand { font-weight: 600; font-size: 15px; }
+.brand .muted { font-weight: 400; margin-left: 6px; }
+.conn { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 13px; }
+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
+.dot.live { background: var(--good); }
+.dot.dead { background: var(--critical); }
+#theme {
+ margin-left: 8px; background: none; border: 1px solid var(--border); color: var(--text-2);
+ border-radius: 6px; width: 28px; height: 28px; cursor: pointer; font-size: 14px;
+}
+#theme:hover { background: var(--wash); }
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px 18px;
+ min-width: 0;
+}
+h2 {
+ margin: 0 0 12px; font-size: 13px; font-weight: 600; letter-spacing: .02em;
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+}
+h2 .sub { font-weight: 400; color: var(--muted); font-size: 12px; }
+
+.grid { display: grid; gap: 16px; }
+.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+@media (max-width: 900px) {
+ .grid.two, .grid.three { grid-template-columns: minmax(0, 1fr); }
+}
+
+/* Clock */
+.clock { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 24px; align-items: center; }
+@media (max-width: 900px) { .clock { grid-template-columns: minmax(0, 1fr); } }
+.clock .label { color: var(--muted); font-size: 12px; letter-spacing: .12em; font-weight: 600; }
+.utc {
+ font-family: var(--mono); font-variant-numeric: tabular-nums;
+ font-size: clamp(44px, 9vw, 104px); line-height: 1.05; font-weight: 600; letter-spacing: -0.02em;
+ white-space: nowrap;
+}
+.date { font-size: clamp(16px, 2.2vw, 22px); color: var(--text-2); margin-top: 4px; }
+
+.status {
+ display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px;
+ border-radius: 999px; font-weight: 600; font-size: 14px;
+ border: 1px solid var(--border); background: var(--wash);
+}
+.status .status-icon { font-size: 12px; }
+.status.good .status-icon { color: var(--good); }
+.status.warning .status-icon { color: var(--warning); }
+.status.critical .status-icon { color: var(--critical); }
+.status.unknown .status-icon { color: var(--muted); }
+.tile .status { margin-top: 6px; padding: 3px 9px; font-size: 12px; gap: 6px; max-width: 100%; white-space: nowrap; overflow: hidden; }
+.tile .status .status-icon { font-size: 10px; }
+.tile .status #status-text { overflow: hidden; text-overflow: ellipsis; }
+.tiles + .reasons { margin: -6px 0 12px; }
+.reasons { margin: 6px 0 10px; padding-left: 18px; color: var(--text-2); font-size: 13px; }
+.reasons:empty { display: none; }
+
+/* Hover help */
+[title].tile, .kv dt[title], th[title], figcaption[title], .snr-head [title] { cursor: help; }
+.kv dt[title], .tile[title] .tile-label, th[title], figcaption[title], .snr-head [title] {
+ text-decoration: underline dotted var(--axis); text-underline-offset: 3px;
+}
+
+/* Key/value lists */
+.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0; }
+.kv dt { color: var(--text-2); }
+.kv dd { margin: 0; font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 13px; overflow-wrap: anywhere; }
+.kv.compact { gap: 2px 14px; font-size: 13px; }
+.kv .srv[hidden] { display: none; }
+
+/* Stat tiles */
+.tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; }
+.tile { background: var(--wash); border-radius: 8px; padding: 10px 12px; min-width: 0; }
+.tile-label { color: var(--text-2); font-size: 12px; }
+.tile-value { font-size: 24px; font-weight: 600; line-height: 1.2; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.tile-value .of { color: var(--muted); font-weight: 400; font-size: 18px; }
+.tile-sub { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+@media (max-width: 480px) { .tile-value { font-size: 19px; } }
+
+.err { color: var(--critical); font-size: 13px; margin: 10px 0 0; }
+
+/* Sources table */
+.table-wrap { overflow-x: auto; }
+table { border-collapse: collapse; width: 100%; font-size: 13px; }
+th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px; padding: 4px 6px 6px; border-bottom: 1px solid var(--axis); white-space: nowrap; }
+td { padding: 6px 6px; border-bottom: 1px solid var(--grid); white-space: nowrap; font-variant-numeric: tabular-nums; }
+td.num, th.num { text-align: right; }
+td.mono { font-family: var(--mono); }
+tr.selected td { background: var(--wash); }
+tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--good); }
+.state { font-family: var(--mono); font-weight: 700; }
+.name-cell { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
+.reach { display: inline-flex; gap: 2px; vertical-align: middle; }
+.reach i { width: 6px; height: 10px; border-radius: 2px; background: var(--grid); display: inline-block; }
+.reach i.on { background: var(--text-2); }
+.legend-note { color: var(--muted); font-size: 12px; margin: 10px 0 0; }
+
+/* Range toggle */
+.range { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
+.range button {
+ background: none; border: 0; color: var(--text-2); font: inherit; font-size: 12px; font-weight: 500;
+ padding: 4px 10px; cursor: pointer;
+}
+.range button + button { border-left: 1px solid var(--border); }
+.range button.on { background: var(--wash); color: var(--text); font-weight: 600; }
+
+/* Charts */
+.chart { margin: 0; position: relative; min-width: 0; }
+.chart figcaption { font-size: 12px; color: var(--text-2); margin-bottom: 4px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
+.chart figcaption .lg { display: inline-flex; align-items: center; gap: 5px; color: var(--text-2); }
+.chart figcaption .lg i { width: 12px; height: 2px; border-radius: 1px; display: inline-block; }
+.chart canvas { width: 100%; height: 160px; display: block; touch-action: pan-y; }
+
+.tip {
+ position: fixed; z-index: 10; pointer-events: none;
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
+ border-radius: 8px; padding: 6px 10px; font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.18);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+.tip .t { color: var(--muted); margin-bottom: 2px; }
+.tip .row { display: flex; align-items: center; gap: 6px; }
+.tip .row i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.tip .row b { margin-left: auto; padding-left: 12px; font-weight: 600; }
+
+/* Sky plot */
+.sky-wrap { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 16px; align-items: start; }
+@media (max-width: 520px) { .sky-wrap { grid-template-columns: minmax(0, 1fr); } #sky { max-width: 300px; margin: 0 auto; } }
+#sky { width: 100%; height: auto; display: block; }
+#sky .ring { fill: none; stroke: var(--grid); stroke-width: 1; }
+#sky .ring.horizon { stroke: var(--axis); }
+#sky .spoke { stroke: var(--grid); stroke-width: 1; }
+#sky .card-pt { fill: var(--muted); font-size: 10px; text-anchor: middle; dominant-baseline: central; }
+#sky .el { fill: var(--muted); font-size: 8px; }
+#sky .sat { stroke: var(--surface); stroke-width: 2; cursor: default; }
+#sky .sat.hollow { fill: var(--surface) !important; stroke-width: 2; }
+#sky .prn { fill: var(--text-2); font-size: 7.5px; text-anchor: middle; pointer-events: none; }
+.sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; border: 2px solid var(--text-2); }
+.sw.filled { background: var(--text-2); }
+.const td:first-child { display: flex; align-items: center; gap: 6px; }
+.const td i { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
+
+/* SNR bars */
+.snr-head { font-size: 12px; color: var(--text-2); margin: 14px 0 6px; }
+.snr { display: flex; gap: 2px; height: 130px; overflow-x: auto; overflow-y: hidden; }
+.snr .col { flex: 1 0 16px; max-width: 28px; display: flex; flex-direction: column; height: 100%; }
+.snr .v { font-size: 10px; color: var(--text-2); text-align: center; font-variant-numeric: tabular-nums; height: 14px; }
+.snr .track { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; border-bottom: 1px solid var(--axis); }
+.snr .fill { border-radius: 4px 4px 0 0; min-height: 2px; }
+.snr .col.unused .fill { opacity: .35; }
+.snr .col:hover .fill { filter: brightness(1.15); }
+.snr .lab { font-size: 10px; color: var(--muted); text-align: center; padding-top: 2px; font-variant-numeric: tabular-nums; }
+
+.foot { text-align: center; font-size: 12px; padding: 4px 16px 20px; }
+.sources .sub2 { font-size: 11px; color: var(--muted); }