Files
pyMC_Repeater/repeater/web/html/gps.html
T
2026-04-23 18:54:19 -04:00

1276 lines
43 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pyMC GPS Diagnostics</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
--color-background: #f5f7fa;
--color-background-mute: #ebeef2;
--color-text-primary: #111827;
--color-text-muted: #6b7280;
--color-border: #9ca3af;
--color-border-subtle: #d1d5db;
--color-primary: #0d7377;
--color-accent-green: #15803d;
--color-accent-red: #dc2626;
--color-glass-bg: rgba(255, 255, 255, 0.75);
--color-glass-border: rgba(0, 0, 0, 0.06);
--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.02);
--bg: var(--color-background);
--surface: var(--color-glass-bg);
--surface-strong: var(--color-surface-elevated);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--line: var(--color-glass-border);
--primary: var(--color-primary);
--green: var(--color-accent-green);
--amber: #92610a;
--red: var(--color-accent-red);
--shadow: var(--color-glass-shadow);
}
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #0f1112;
--color-surface-elevated: #1a1e1f;
--color-background: #09090b;
--color-background-mute: #1a1e1f;
--color-text-primary: #f9fafb;
--color-text-muted: #9ca3af;
--color-border: #4b4b4b;
--color-border-subtle: #374151;
--color-primary: #aae8e8;
--color-accent-green: #a5e5b6;
--color-accent-red: #fb787b;
--color-glass-bg: rgba(0, 0, 0, 0.40);
--color-glass-border: rgba(255, 255, 255, 0.05);
--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, 0.20);
--amber: #ffc246;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 18% 14%, color-mix(in srgb, var(--primary) 28%, transparent), transparent 34rem),
radial-gradient(circle at 88% 12%, color-mix(in srgb, var(--green) 18%, transparent), transparent 28rem),
linear-gradient(135deg, var(--bg), color-mix(in srgb, var(--bg) 84%, #0d7377));
color: var(--text);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Noto Sans", sans-serif;
}
a {
color: var(--primary);
text-decoration: none;
}
.shell {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 42px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.brand {
display: flex;
flex-direction: column;
gap: 4px;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 46px);
line-height: 1.05;
letter-spacing: -0.04em;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.nav-link {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
color: var(--text);
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 9px 12px;
font: inherit;
font-size: 14px;
font-weight: 600;
box-shadow: var(--shadow);
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.nav-link:hover {
border-color: color-mix(in srgb, var(--primary) 45%, var(--line));
background: color-mix(in srgb, var(--surface) 82%, var(--primary));
color: var(--primary);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.6fr);
gap: 16px;
margin-bottom: 16px;
}
.panel {
border: 1px solid var(--line);
border-radius: 22px;
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(22px);
padding: 18px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.panel-subtitle {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.pill {
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--line));
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
padding: 5px 9px;
font-size: 12px;
font-weight: 700;
}
.status-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.globe-panel {
min-height: 100%;
overflow: hidden;
}
.globe-stage {
position: relative;
height: 320px;
min-height: 280px;
border: 1px solid var(--line);
border-radius: 18px;
background:
radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--primary) 24%, transparent), transparent 34%),
radial-gradient(circle at 48% 50%, rgba(255, 255, 255, 0.14), transparent 20%),
linear-gradient(145deg, color-mix(in srgb, var(--color-background-mute) 74%, #000), color-mix(in srgb, var(--color-background) 82%, #000));
overflow: hidden;
cursor: grab;
touch-action: none;
}
.globe-stage:active {
cursor: grabbing;
}
#satelliteGlobe {
display: block;
width: 100%;
height: 100%;
}
.globe-tooltip {
position: absolute;
z-index: 2;
min-width: 154px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
background: rgba(5, 10, 12, 0.92);
color: #ffffff;
padding: 10px 11px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.36);
pointer-events: none;
opacity: 0;
transform: translate(-50%, calc(-100% - 16px)) scale(0.96);
transition: opacity 0.16s ease, transform 0.16s ease;
}
.globe-tooltip[data-visible="true"] {
opacity: 1;
transform: translate(-50%, calc(-100% - 22px)) scale(1);
}
.tooltip-title {
color: var(--green);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tooltip-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
margin-top: 7px;
font-size: 12px;
}
.tooltip-key {
color: rgba(255, 255, 255, 0.66);
}
.tooltip-value {
color: #ffffff;
font-weight: 700;
text-align: right;
}
.globe-fallback {
position: absolute;
inset: 0;
padding: 18px;
}
.fallback-sky {
position: relative;
width: min(250px, 72vw);
aspect-ratio: 1;
margin: 0 auto;
border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--line));
border-radius: 50%;
background:
radial-gradient(circle, color-mix(in srgb, var(--primary) 18%, transparent) 0 2px, transparent 3px),
repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--line) 70%, transparent) 31.5% 32%, transparent 32.5% 49%),
linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--line) 78%, transparent) 49.7% 50.3%, transparent 50.3%),
linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--line) 78%, transparent) 49.7% 50.3%, transparent 50.3%);
}
.fallback-sat {
position: absolute;
width: var(--size);
height: var(--size);
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 16px color-mix(in srgb, var(--green) 70%, transparent);
transform: translate(-50%, -50%);
}
.fallback-sat span {
position: absolute;
top: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
color: var(--text);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.globe-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 12px;
color: var(--muted);
font-size: 12px;
}
.metric {
min-height: 118px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid var(--line);
padding: 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.metric-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
}
.metric-note {
color: var(--muted);
font-size: 12px;
}
.ok {
color: var(--green);
}
.warn {
color: var(--amber);
}
.bad {
color: var(--red);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
h2 {
margin: 0 0 12px;
font-size: 17px;
}
.kv {
display: grid;
grid-template-columns: minmax(130px, 0.7fr) minmax(0, 1fr);
gap: 8px 14px;
font-size: 14px;
}
.key {
color: var(--muted);
}
.value {
font-weight: 600;
overflow-wrap: anywhere;
}
.wide {
grid-column: 1 / -1;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 9px 8px;
text-align: left;
}
th {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
pre {
max-height: 360px;
overflow: auto;
margin: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: color-mix(in srgb, var(--surface-strong) 88%, #000);
color: var(--text);
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap;
}
.error {
display: none;
border: 1px solid color-mix(in srgb, var(--red) 45%, transparent);
background: color-mix(in srgb, var(--red) 10%, transparent);
color: var(--red);
border-radius: 16px;
padding: 12px 14px;
margin-bottom: 16px;
}
.empty {
color: var(--muted);
font-style: italic;
}
@media (max-width: 1050px) {
.hero,
.grid {
grid-template-columns: 1fr;
}
.globe-stage {
height: 300px;
}
.status-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.shell {
width: min(100vw - 20px, 1440px);
padding-top: 12px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.status-panel {
grid-template-columns: 1fr;
}
.panel-header,
.globe-footer {
align-items: flex-start;
flex-direction: column;
}
.kv {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand">
<h1>GPS Diagnostics</h1>
<p class="subtitle">Live NMEA receiver state, parsed fix data, and satellite visibility.</p>
</div>
<div class="actions">
<a class="nav-link" href="/">Back to Dashboard</a>
</div>
</header>
<div id="error" class="error"></div>
<section class="hero">
<div class="panel status-panel">
<div class="metric">
<div class="metric-label">Fix State</div>
<div id="fixState" class="metric-value">Loading</div>
<div id="fixNote" class="metric-note">Waiting for /api/gps</div>
</div>
<div class="metric">
<div class="metric-label">Coordinates</div>
<div id="coords" class="metric-value">n/a</div>
<div id="coordsNote" class="metric-note">latitude, longitude</div>
</div>
<div class="metric">
<div class="metric-label">Satellites</div>
<div id="satSummary" class="metric-value">n/a</div>
<div id="satNote" class="metric-note">used / in view</div>
</div>
<div class="metric">
<div class="metric-label">Freshness</div>
<div id="freshness" class="metric-value">n/a</div>
<div id="lastUpdate" class="metric-note">last update unknown</div>
</div>
</div>
<div class="panel globe-panel">
<div class="panel-header">
<div>
<h2>Satellite Globe</h2>
<p class="panel-subtitle">User-centered sky view from satellite azimuth, elevation, and SNR.</p>
</div>
<span id="globeCount" class="pill">0 sats</span>
</div>
<div id="globeStage" class="globe-stage">
<canvas id="satelliteGlobe" aria-label="3D globe showing satellites around the current receiver position"></canvas>
<div id="globeFallback" class="globe-fallback" hidden></div>
<div id="globeTooltip" class="globe-tooltip" data-visible="false" role="status" aria-live="polite"></div>
</div>
<div class="globe-footer">
<span id="globeStatus">Waiting for satellite data</span>
<span>Drag to rotate. Scroll to zoom. Hover a satellite for details.</span>
</div>
</div>
</section>
<section class="grid">
<div class="panel">
<h2>Receiver Source</h2>
<div id="sourceKv" class="kv"></div>
</div>
<div class="panel">
<h2>Fix</h2>
<div id="fixKv" class="kv"></div>
</div>
<div class="panel">
<h2>Position</h2>
<div id="positionKv" class="kv"></div>
</div>
<div class="panel">
<h2>Motion</h2>
<div id="motionKv" class="kv"></div>
</div>
<div class="panel">
<h2>Accuracy</h2>
<div id="accuracyKv" class="kv"></div>
</div>
<div class="panel">
<h2>Time</h2>
<div id="timeKv" class="kv"></div>
</div>
<div class="panel">
<h2>NMEA Health</h2>
<div id="nmeaKv" class="kv"></div>
</div>
<div class="panel wide">
<h2>Satellites In View + SNR</h2>
<div id="satelliteTable"></div>
</div>
<div class="panel wide">
<h2>Recent NMEA Sentences</h2>
<div id="sentenceTable"></div>
</div>
<div class="panel wide">
<h2>Raw Snapshot</h2>
<pre id="rawJson">{}</pre>
</div>
</section>
</main>
<script src="https://unpkg.com/three@0.128.0/build/three.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/three@0.128.0/examples/js/controls/OrbitControls.js" crossorigin="anonymous"></script>
<script>
const TOKEN_KEY = "pymc_jwt_token";
const stateClass = {
valid_fix: "ok",
invalid_fix: "warn",
stale: "warn",
no_data: "warn",
disabled: "warn",
error: "bad"
};
const REFRESH_INTERVAL_MS = 2500;
const EARTH_TEXTURE_URL = "https://cdn.jsdelivr.net/gh/mrdoob/three.js@r128/examples/textures/planets/earth_atmos_2048.jpg";
let timer = null;
let globeRenderer = null;
function token() {
return localStorage.getItem(TOKEN_KEY);
}
function cssVar(name, fallback) {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function asNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function getPositionMeta(data) {
const status = data.status || {};
const meta = data.position_meta || {};
const gpsFixValid = meta.gps_fix_valid ?? Boolean(status.fix_valid || status.state === "valid_fix");
return {
source: meta.source || "gps",
source_label: meta.source_label || (gpsFixValid ? "GPS fix" : "GPS estimate"),
policy: meta.policy || "gps_only",
manual_config_available: Boolean(meta.manual_config_available),
gps_fix_valid: gpsFixValid
};
}
function createFallbackGlobe() {
const canvas = document.getElementById("satelliteGlobe");
const fallback = document.getElementById("globeFallback");
if (canvas) {
canvas.style.display = "none";
}
if (fallback) {
fallback.hidden = false;
}
return {
update(data) {
const satellites = (data.satellites || {}).in_view || [];
const positionMeta = getPositionMeta(data);
const count = satellites.length;
document.getElementById("globeCount").textContent = `${count} sat${count === 1 ? "" : "s"}`;
document.getElementById("globeStatus").textContent =
count
? `2D fallback: ${fmt(positionMeta.source_label || "receiver position")} + az/el sky plot`
: "Waiting for satellites in view";
if (!fallback) return;
if (!count) {
fallback.innerHTML = '<div class="empty">No satellites in view yet</div>';
return;
}
const markers = satellites.map((satellite) => {
const azimuth = asNumber(satellite.azimuth_degrees) ?? 0;
const elevation = Math.max(0, Math.min(90, asNumber(satellite.elevation_degrees) ?? 0));
const snr = Math.max(0, Math.min(60, asNumber(satellite.snr_db) ?? 0));
const radius = (1 - (elevation / 90)) * 44;
const azimuthRad = azimuth * Math.PI / 180;
const x = 50 + Math.sin(azimuthRad) * radius;
const y = 50 - Math.cos(azimuthRad) * radius;
const size = 8 + (snr / 60) * 12;
return `
<div class="fallback-sat" style="left: ${x}%; top: ${y}%; --size: ${size}px">
<span>${escapeHtml(satellite.prn || "?")}</span>
</div>
`;
}).join("");
fallback.innerHTML = `<div class="fallback-sky" aria-label="Fallback satellite sky plot">${markers}</div>`;
}
};
}
function createSatelliteGlobe() {
const canvas = document.getElementById("satelliteGlobe");
const stage = document.getElementById("globeStage");
const fallback = document.getElementById("globeFallback");
const tooltip = document.getElementById("globeTooltip");
if (!canvas || !stage || !window.THREE) {
return createFallbackGlobe();
}
const THREE = window.THREE;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
powerPreference: "low-power"
});
const root = new THREE.Group();
const satelliteGroup = new THREE.Group();
const linkGroup = new THREE.Group();
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hoverScale = new THREE.Vector3();
let controls = null;
let satelliteMeshes = [];
let hoveredSatellite = null;
let lastPointer = null;
camera.position.set(0, 0.10, 3.72);
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
if (THREE.OrbitControls) {
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 2.45;
controls.maxDistance = 5.6;
controls.rotateSpeed = 0.55;
controls.zoomSpeed = 0.72;
controls.target.set(0, 0, 0);
controls.update();
canvas.addEventListener("wheel", (event) => event.preventDefault(), { passive: false });
}
scene.add(new THREE.AmbientLight(0xffffff, 0.84));
const keyLight = new THREE.DirectionalLight(0xffffff, 0.78);
keyLight.position.set(2.7, 2.2, 3.4);
scene.add(keyLight);
const earthMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color(cssVar("--color-primary", "#0d7377")),
roughness: 0.62,
metalness: 0.04
});
const earth = new THREE.Mesh(new THREE.SphereGeometry(1, 56, 36), earthMaterial);
new THREE.TextureLoader().load(
EARTH_TEXTURE_URL,
(texture) => {
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
earthMaterial.map = texture;
earthMaterial.color.set(0xffffff);
earthMaterial.roughness = 0.82;
earthMaterial.needsUpdate = true;
},
undefined,
() => {
document.getElementById("globeStatus").textContent = "Earth texture unavailable; using local fallback globe";
}
);
const grid = new THREE.Mesh(
new THREE.SphereGeometry(1.006, 28, 18),
new THREE.MeshBasicMaterial({
color: new THREE.Color(cssVar("--color-primary", "#aae8e8")),
wireframe: true,
transparent: true,
opacity: 0.16
})
);
const atmosphere = new THREE.Mesh(
new THREE.SphereGeometry(1.045, 48, 24),
new THREE.MeshBasicMaterial({
color: new THREE.Color(cssVar("--color-primary", "#aae8e8")),
transparent: true,
opacity: 0.10,
side: THREE.BackSide
})
);
root.add(earth, grid, atmosphere, linkGroup, satelliteGroup);
scene.add(root);
function resize() {
const rect = stage.getBoundingClientRect();
const width = Math.max(240, Math.floor(rect.width));
const height = Math.max(240, Math.floor(rect.height));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function disposeObject(object) {
if (object.geometry) {
object.geometry.dispose();
}
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
if (material.map) material.map.dispose();
material.dispose();
});
}
function clearGroup(group) {
while (group.children.length) {
const child = group.children.pop();
child.traverse(disposeObject);
group.remove(child);
}
}
function latLonVector(latitude, longitude, radius = 1) {
const lat = latitude * Math.PI / 180;
const lon = longitude * Math.PI / 180;
return new THREE.Vector3(
Math.cos(lat) * Math.sin(lon),
Math.sin(lat),
Math.cos(lat) * Math.cos(lon)
).multiplyScalar(radius);
}
function localBasis(latitude, longitude) {
const lon = longitude * Math.PI / 180;
const up = latLonVector(latitude, longitude, 1).normalize();
const east = new THREE.Vector3(Math.cos(lon), 0, -Math.sin(lon)).normalize();
const north = up.clone().cross(east).normalize();
return { east, north, up };
}
function addLine(points, color, opacity = 0.34) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity
});
linkGroup.add(new THREE.Line(geometry, material));
}
function roundedRect(context, x, y, width, height, radius) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
}
function addLabel(text, position, color, scale = 1) {
const labelCanvas = document.createElement("canvas");
labelCanvas.width = 192;
labelCanvas.height = 76;
const context = labelCanvas.getContext("2d");
context.font = "800 28px Inter, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
roundedRect(context, 20, 14, 152, 48, 16);
context.fillStyle = "rgba(3, 8, 10, 0.88)";
context.fill();
context.lineWidth = 3;
context.strokeStyle = color;
context.stroke();
context.fillStyle = "#ffffff";
context.shadowColor = "rgba(0, 0, 0, 0.92)";
context.shadowBlur = 7;
context.fillText(text, 96, 39);
const texture = new THREE.CanvasTexture(labelCanvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.98,
depthTest: false,
depthWrite: false
}));
sprite.position.copy(position);
sprite.scale.set(0.30 * scale, 0.12 * scale, 1);
sprite.renderOrder = 10;
satelliteGroup.add(sprite);
}
function hideTooltip(clearPointer = false) {
hoveredSatellite = null;
if (clearPointer) {
lastPointer = null;
}
if (tooltip) {
tooltip.dataset.visible = "false";
}
}
function showTooltip(mesh, event) {
if (!tooltip || !mesh) return;
const satellite = mesh.userData.satellite || {};
const rect = stage.getBoundingClientRect();
const x = Math.max(86, Math.min(rect.width - 86, event.clientX - rect.left));
const y = Math.max(84, Math.min(rect.height - 12, event.clientY - rect.top));
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
tooltip.innerHTML = `
<div class="tooltip-title">SAT ${escapeHtml(satellite.prn || "?")}</div>
<div class="tooltip-grid">
<span class="tooltip-key">SNR</span><span class="tooltip-value">${escapeHtml(fmt(satellite.snr_db, "dB"))}</span>
<span class="tooltip-key">Elevation</span><span class="tooltip-value">${escapeHtml(fmt(satellite.elevation_degrees, "deg"))}</span>
<span class="tooltip-key">Azimuth</span><span class="tooltip-value">${escapeHtml(fmt(satellite.azimuth_degrees, "deg"))}</span>
<span class="tooltip-key">Used in fix</span><span class="tooltip-value">${mesh.userData.used ? "yes" : "no"}</span>
</div>
`;
tooltip.dataset.visible = "true";
}
function handlePointerMove(event) {
lastPointer = { clientX: event.clientX, clientY: event.clientY };
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(satelliteMeshes, false)[0];
hoveredSatellite = hit ? hit.object : null;
if (hoveredSatellite) {
showTooltip(hoveredSatellite, event);
} else {
hideTooltip();
}
}
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", () => hideTooltip(true));
function addSatellite(satellite, basis, userPosition, usedPrns) {
const azimuth = asNumber(satellite.azimuth_degrees);
const elevation = asNumber(satellite.elevation_degrees);
if (azimuth === null || elevation === null) return;
const az = azimuth * Math.PI / 180;
const el = Math.max(0, Math.min(90, elevation)) * Math.PI / 180;
const horizon = basis.north.clone().multiplyScalar(Math.cos(az))
.add(basis.east.clone().multiplyScalar(Math.sin(az)));
const direction = horizon.multiplyScalar(Math.cos(el))
.add(basis.up.clone().multiplyScalar(Math.sin(el)))
.normalize();
const position = userPosition.clone().add(direction.multiplyScalar(0.62));
const prn = String(satellite.prn || "?");
const snr = Math.max(0, Math.min(60, asNumber(satellite.snr_db) ?? 0));
const isUsed = usedPrns.has(prn);
const color = isUsed ? cssVar("--green", "#a5e5b6") : cssVar("--primary", "#aae8e8");
const radius = 0.024 + (snr / 60) * 0.042;
const marker = new THREE.Mesh(
new THREE.SphereGeometry(radius, 16, 12),
new THREE.MeshBasicMaterial({ color: new THREE.Color(color) })
);
marker.position.copy(position);
marker.userData = { satellite, used: isUsed, baseScale: 1 };
satelliteMeshes.push(marker);
satelliteGroup.add(marker);
addLine([userPosition.clone().multiplyScalar(1.012), position], color, isUsed ? 0.46 : 0.26);
addLabel(prn, position.clone().multiplyScalar(1.035), color, 0.88);
}
function addReceiver(latitude, longitude) {
const userPosition = latLonVector(latitude, longitude, 1.022);
const basis = localBasis(latitude, longitude);
const receiverColor = cssVar("--green", "#a5e5b6");
const receiver = new THREE.Mesh(
new THREE.SphereGeometry(0.032, 18, 14),
new THREE.MeshBasicMaterial({ color: new THREE.Color(receiverColor) })
);
receiver.position.copy(userPosition);
satelliteGroup.add(receiver);
addLabel("YOU", userPosition.clone().multiplyScalar(1.08), receiverColor, 1);
const ringPoints = [];
for (let index = 0; index <= 96; index += 1) {
const angle = (index / 96) * Math.PI * 2;
ringPoints.push(
userPosition.clone()
.add(basis.north.clone().multiplyScalar(Math.cos(angle) * 0.62))
.add(basis.east.clone().multiplyScalar(Math.sin(angle) * 0.62))
);
}
addLine(ringPoints, receiverColor, 0.28);
return { basis, userPosition };
}
function update(data) {
const position = data.position || {};
const satellites = data.satellites || {};
const positionMeta = getPositionMeta(data);
const latitude = asNumber(position.latitude);
const longitude = asNumber(position.longitude);
const inView = (satellites.in_view || []).filter((satellite) =>
asNumber(satellite.azimuth_degrees) !== null && asNumber(satellite.elevation_degrees) !== null
);
const usedPrns = new Set((satellites.used_prns || []).map(String));
const pointerToRestore = lastPointer;
document.getElementById("globeCount").textContent = `${inView.length} sat${inView.length === 1 ? "" : "s"}`;
satelliteMeshes = [];
hoveredSatellite = null;
clearGroup(satelliteGroup);
clearGroup(linkGroup);
if (latitude === null || longitude === null) {
document.getElementById("globeStatus").textContent = "Waiting for receiver position";
return;
}
const userDirection = latLonVector(latitude, longitude, 1).normalize();
const targetDirection = new THREE.Vector3(0.10, 0.10, 1).normalize();
root.quaternion.copy(new THREE.Quaternion().setFromUnitVectors(userDirection, targetDirection));
const { basis, userPosition } = addReceiver(latitude, longitude);
inView.forEach((satellite) => addSatellite(satellite, basis, userPosition, usedPrns));
document.getElementById("globeStatus").textContent =
inView.length
? `${inView.length} satellites plotted from GSV az/el using ${fmt(positionMeta.source_label || "receiver position")}`
: "No satellites in view yet";
if (pointerToRestore) {
window.requestAnimationFrame(() => handlePointerMove(pointerToRestore));
} else {
hideTooltip();
}
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(stage);
resize();
function animate() {
if (!reducedMotion) {
atmosphere.rotation.y += 0.0012;
grid.rotation.y += 0.0005;
}
if (controls) {
controls.update();
}
satelliteMeshes.forEach((mesh) => {
const target = mesh === hoveredSatellite ? 1.75 : 1;
hoverScale.set(target, target, target);
mesh.scale.lerp(hoverScale, 0.18);
});
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
animate();
if (fallback) {
fallback.hidden = true;
}
return { update };
}
function updateSatelliteGlobe(data) {
try {
if (!globeRenderer) {
globeRenderer = createSatelliteGlobe();
}
globeRenderer.update(data);
} catch (error) {
console.warn("Satellite globe failed; using fallback", error);
globeRenderer = createFallbackGlobe();
globeRenderer.update(data);
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fmt(value, unit) {
if (value === null || value === undefined || value === "") return "n/a";
if (typeof value === "boolean") return value ? "yes" : "no";
if (Array.isArray(value)) return value.length ? value.join(", ") : "none";
if (typeof value === "number") return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)));
return `${value}${unit ? " " + unit : ""}`;
}
function setError(message) {
const box = document.getElementById("error");
if (!message) {
box.style.display = "none";
box.textContent = "";
return;
}
box.style.display = "block";
box.textContent = message;
}
function renderKv(containerId, rows) {
const container = document.getElementById(containerId);
container.innerHTML = rows.map(([key, value, unit]) => `
<div class="key">${escapeHtml(key)}</div>
<div class="value">${escapeHtml(fmt(value, unit))}</div>
`).join("");
}
function renderTable(containerId, columns, rows, emptyText) {
const container = document.getElementById(containerId);
if (!rows || rows.length === 0) {
container.innerHTML = `<div class="empty">${escapeHtml(emptyText || "No data")}</div>`;
return;
}
container.innerHTML = `
<table>
<thead>
<tr>${columns.map(col => `<th>${escapeHtml(col.label)}</th>`).join("")}</tr>
</thead>
<tbody>
${rows.map(row => `
<tr>
${columns.map(col => `<td>${escapeHtml(fmt(row[col.key], col.unit))}</td>`).join("")}
</tr>
`).join("")}
</tbody>
</table>
`;
}
function render(data) {
const status = data.status || {};
const fix = data.fix || {};
const position = data.position || {};
const gpsPosition = data.gps_position || position;
const manualPosition = data.manual_position || {};
const positionMeta = getPositionMeta(data);
const motion = data.motion || {};
const accuracy = data.accuracy || {};
const time = data.time || {};
const satellites = data.satellites || {};
const nmea = data.nmea || {};
const source = data.source || {};
const state = status.state || "unknown";
const fixState = document.getElementById("fixState");
fixState.textContent = state.replaceAll("_", " ");
fixState.className = `metric-value ${stateClass[state] || ""}`;
document.getElementById("fixNote").textContent = status.last_error || `quality: ${fmt(fix.quality_label)}`;
const lat = position.latitude;
const lon = position.longitude;
document.getElementById("coords").textContent =
lat !== null && lat !== undefined && lon !== null && lon !== undefined
? `${fmt(lat)}, ${fmt(lon)}`
: "n/a";
document.getElementById("coordsNote").textContent =
`${fmt(positionMeta.source_label || "position source unknown")} | altitude: ${fmt(position.altitude_m, "m")}`;
document.getElementById("satSummary").textContent =
`${fmt(satellites.used_count)} / ${fmt(satellites.in_view_count)}`;
document.getElementById("satNote").textContent =
`SNR avg: ${fmt((satellites.snr || {}).avg, "dB")}`;
document.getElementById("freshness").textContent =
status.age_seconds === null || status.age_seconds === undefined ? "n/a" : `${fmt(status.age_seconds)}s`;
document.getElementById("lastUpdate").textContent = status.last_update || "last update unknown";
renderKv("sourceKv", [
["enabled", data.enabled],
["running", data.running],
["source type", source.type],
["device", source.device],
["baud rate", source.baud_rate],
["source path", source.source_path],
["read timeout", source.read_timeout_seconds, "s"],
["stale after", source.stale_after_seconds, "s"]
]);
renderKv("fixKv", [
["valid", fix.valid],
["status", fix.status],
["quality", fix.quality],
["quality label", fix.quality_label],
["GSA fix type", fix.gsa_fix_type],
["GSA label", fix.gsa_fix_type_label]
]);
renderKv("positionKv", [
["display source", positionMeta.source_label || positionMeta.source],
["policy", positionMeta.policy],
["display latitude", position.latitude],
["display longitude", position.longitude],
["GPS latitude", gpsPosition.latitude],
["GPS longitude", gpsPosition.longitude],
["manual latitude", manualPosition.latitude],
["manual longitude", manualPosition.longitude],
["altitude", position.altitude_m, "m"],
["geoid separation", position.geoid_separation_m, "m"]
]);
renderKv("motionKv", [
["speed", motion.speed_knots, "knots"],
["speed", motion.speed_kmh, "km/h"],
["course", motion.course_degrees, "deg"],
["magnetic variation", motion.magnetic_variation_degrees, "deg"]
]);
renderKv("accuracyKv", [
["HDOP", accuracy.hdop],
["PDOP", accuracy.pdop],
["VDOP", accuracy.vdop]
]);
renderKv("timeKv", [
["UTC time", time.utc_time],
["date", time.date],
["datetime UTC", time.datetime_utc]
]);
renderKv("nmeaKv", [
["last talker", nmea.last_talker],
["last sentence type", nmea.last_sentence_type],
["seen sentence types", nmea.seen_sentence_types],
["valid checksums", nmea.valid_checksum_count],
["invalid checksums", nmea.invalid_checksum_count],
["missing checksums", nmea.missing_checksum_count],
["last sentence", nmea.last_sentence]
]);
renderTable("satelliteTable", [
{ key: "prn", label: "PRN" },
{ key: "elevation_degrees", label: "Elevation", unit: "deg" },
{ key: "azimuth_degrees", label: "Azimuth", unit: "deg" },
{ key: "snr_db", label: "SNR", unit: "dB" }
], satellites.in_view || [], "No satellites in view yet");
renderTable("sentenceTable", [
{ key: "timestamp", label: "Timestamp" },
{ key: "sentence_type", label: "Type" },
{ key: "sentence", label: "Sentence" }
], nmea.recent_sentences || [], "No NMEA sentences captured yet");
updateSatelliteGlobe(data);
document.getElementById("rawJson").textContent = JSON.stringify(data, null, 2);
}
async function loadGps() {
const jwt = token();
if (!jwt) {
window.location.href = "/login";
return;
}
try {
setError("");
const response = await fetch("/api/gps", {
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${jwt}`
}
});
if (response.status === 401 || response.status === 403) {
window.location.href = "/login";
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (payload.success === false) {
throw new Error(payload.error || "GPS API error");
}
render(payload.data || payload);
} catch (error) {
setError(`Failed to load GPS diagnostics: ${error.message || error}`);
}
}
function configureTimer() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
timer = window.setInterval(loadGps, REFRESH_INTERVAL_MS);
}
configureTimer();
loadGps();
</script>
</body>
</html>