'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 += ``;
const stateTxt = x.state === '-' ? '−' : x.state;
return `
| ${stateTxt} |
${esc(x.name)} |
${x.mode === '#' ? type : `${type === 'peer' ? 'peer' : 'NTP'} s${x.stratum}`} |
${fmtDur(2 ** x.poll)} |
${dots} |
${x.lastRx < 0 ? '—' : fmtDur(x.lastRx)} |
${fmtSec(x.offset)} ±${fmtSec(x.error, false)}${x.stdDev != null ? ` · σ ${fmtSec(x.stdDev, false)}` : ''} |
`;
});
$('sources').innerHTML = rows.join('') || '| No sources |
';
}
function esc(s) { return String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 += ``;
for (let az = 0; az < 360; az += 45) {
const r = (az * Math.PI) / 180;
h += ``;
}
h += `NESW`;
h += `30°60°`;
return h + '';
}
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 += ``;
h += `${x.svid ?? x.PRN}`;
}
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 `| ${c.name} | ${e.used} | ${e.seen} |
`;
}).join('') || '| No satellites |
';
// 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 `
${ss ? Math.round(ss) : ''}
${x.svid ?? x.PRN} `;
}).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 = `${esc(parts[0])}
`;
for (let i = 1; i + 1 < parts.length; i += 2) h += `${esc(parts[i])}${esc(parts[i + 1])}
`;
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) => ` ${s.label}`).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 = `${utcHMS(hp.t)} UTC
`;
for (const s of this.series) h += `${s.label}${hp[s.key] == null ? '—' : this.fmtTip(hp[s.key])}
`;
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) => `${esc(r)}`).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);