From 4ab66536b650e608039c0c3a205da897cb8bd2c5 Mon Sep 17 00:00:00 2001 From: Jason McCormick Date: Fri, 6 Nov 2026 19:10:44 -0500 Subject: new --- web/app.js | 552 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 web/app.js (limited to 'web/app.js') 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 += ``; + 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); -- cgit v1.2.3