summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
authorJason McCormick <jason@mfamily.org>2026-11-06 19:10:44 -0500
committerJason McCormick <jason@mfamily.org>2026-11-06 19:10:44 -0500
commit4ab66536b650e608039c0c3a205da897cb8bd2c5 (patch)
tree2bc890404b0ff33907383b64acc231cb0a777653 /web/app.js
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js552
1 files changed, 552 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);