'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);