mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-06 00:43:12 +02:00
1276 lines
43 KiB
HTML
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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>
|