/** * GPS Mode * Live GPS data display with satellite sky view, signal strength bars, * position/velocity/DOP readout. Connects to gpsd via backend SSE stream. */ const GPS = (function() { let connected = false; let lastPosition = null; let lastSky = null; let skyPollTimer = null; let themeObserver = null; let skyRenderer = null; let skyRendererInitAttempted = false; // Constellation color map const CONST_COLORS = { 'GPS': '#00d4ff', 'GLONASS': '#00ff88', 'Galileo': '#ff8800', 'BeiDou': '#ff4466', 'SBAS': '#ffdd00', 'QZSS': '#cc66ff', }; function init() { initSkyRenderer(); drawEmptySkyView(); if (!connected) connect(); // Redraw sky view when theme changes if (!themeObserver) { themeObserver = new MutationObserver(() => { if (skyRenderer && typeof skyRenderer.requestRender === 'function') { skyRenderer.requestRender(); } if (lastSky) { drawSkyView(lastSky.satellites || []); } else { drawEmptySkyView(); } }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } if (lastPosition) updatePositionUI(lastPosition); if (lastSky) updateSkyUI(lastSky); } function initSkyRenderer() { if (skyRendererInitAttempted) return; skyRendererInitAttempted = true; const canvas = document.getElementById('gpsSkyCanvas'); if (!canvas) return; const overlay = document.getElementById('gpsSkyOverlay'); try { skyRenderer = createWebGlSkyRenderer(canvas, overlay); } catch (err) { skyRenderer = null; console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); } } function connect() { updateConnectionUI(false, false, 'connecting'); fetch('/gps/auto-connect', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status === 'connected') { connected = true; updateConnectionUI(true, data.has_fix); if (data.position) { lastPosition = data.position; updatePositionUI(data.position); } if (data.sky) { lastSky = data.sky; updateSkyUI(data.sky); } subscribeToStream(); startSkyPolling(); // Ensure the global GPS stream is running if (typeof startGpsStream === 'function' && !gpsEventSource) { startGpsStream(); } } else { connected = false; updateConnectionUI(false, false, 'error', data.message || 'gpsd not available'); } }) .catch(() => { connected = false; updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?'); }); } function disconnect() { unsubscribeFromStream(); stopSkyPolling(); fetch('/gps/stop', { method: 'POST' }) .then(() => { connected = false; updateConnectionUI(false); }); } function onGpsStreamData(data) { if (!connected) return; if (data.type === 'position') { lastPosition = data; updatePositionUI(data); updateConnectionUI(true, true); } else if (data.type === 'sky') { lastSky = data; updateSkyUI(data); } } function startSkyPolling() { stopSkyPolling(); // Poll satellite data every 5 seconds as a reliable fallback // SSE stream may miss sky updates due to queue contention with position messages pollSatellites(); skyPollTimer = setInterval(pollSatellites, 5000); } function stopSkyPolling() { if (skyPollTimer) { clearInterval(skyPollTimer); skyPollTimer = null; } } function pollSatellites() { if (!connected) return; fetch('/gps/satellites') .then(r => r.json()) .then(data => { if (data.status === 'ok' && data.sky) { lastSky = data.sky; updateSkyUI(data.sky); } }) .catch(() => {}); } function subscribeToStream() { // Subscribe to the global GPS stream instead of opening a separate SSE connection if (typeof addGpsStreamSubscriber === 'function') { addGpsStreamSubscriber(onGpsStreamData); } } function unsubscribeFromStream() { if (typeof removeGpsStreamSubscriber === 'function') { removeGpsStreamSubscriber(onGpsStreamData); } } // ======================== // UI Updates // ======================== function updateConnectionUI(isConnected, hasFix, state, message) { const dot = document.getElementById('gpsStatusDot'); const text = document.getElementById('gpsStatusText'); const connectBtn = document.getElementById('gpsConnectBtn'); const disconnectBtn = document.getElementById('gpsDisconnectBtn'); const devicePath = document.getElementById('gpsDevicePath'); if (dot) { dot.className = 'gps-status-dot'; if (state === 'connecting') dot.classList.add('waiting'); else if (state === 'error') dot.classList.add('error'); else if (isConnected && hasFix) dot.classList.add('connected'); else if (isConnected) dot.classList.add('waiting'); } if (text) { if (state === 'connecting') text.textContent = 'Connecting...'; else if (state === 'error') text.textContent = message || 'Connection failed'; else if (isConnected && hasFix) text.textContent = 'Connected (Fix)'; else if (isConnected) text.textContent = 'Connected (No Fix)'; else text.textContent = 'Disconnected'; } if (connectBtn) { connectBtn.style.display = isConnected ? 'none' : ''; connectBtn.disabled = state === 'connecting'; } if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none'; if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : ''; } function updatePositionUI(pos) { // Sidebar fields setText('gpsLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---'); setText('gpsLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---'); setText('gpsAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---'); setText('gpsSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---'); setText('gpsHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---'); setText('gpsClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---'); // Fix type const fixEl = document.getElementById('gpsFixType'); if (fixEl) { const fq = pos.fix_quality; if (fq === 3) fixEl.innerHTML = '3D FIX'; else if (fq === 2) fixEl.innerHTML = '2D FIX'; else fixEl.innerHTML = 'NO FIX'; } // Error estimates const eph = (pos.epx != null && pos.epy != null) ? Math.sqrt(pos.epx * pos.epx + pos.epy * pos.epy) : null; setText('gpsEph', eph != null ? eph.toFixed(1) + ' m' : '---'); setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---'); setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---'); // GPS time if (pos.timestamp) { const t = new Date(pos.timestamp); setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')); } // Visuals: position panel setText('gpsVisPosLat', pos.latitude != null ? pos.latitude.toFixed(6) + '\u00b0' : '---'); setText('gpsVisPosLon', pos.longitude != null ? pos.longitude.toFixed(6) + '\u00b0' : '---'); setText('gpsVisPosAlt', pos.altitude != null ? pos.altitude.toFixed(1) + ' m' : '---'); setText('gpsVisPosSpeed', pos.speed != null ? (pos.speed * 3.6).toFixed(1) + ' km/h' : '---'); setText('gpsVisPosHeading', pos.heading != null ? pos.heading.toFixed(1) + '\u00b0' : '---'); setText('gpsVisPosClimb', pos.climb != null ? pos.climb.toFixed(2) + ' m/s' : '---'); // Visuals: fix badge const visFixEl = document.getElementById('gpsVisFixBadge'); if (visFixEl) { const fq = pos.fix_quality; if (fq === 3) { visFixEl.textContent = '3D FIX'; visFixEl.className = 'gps-fix-badge fix-3d'; } else if (fq === 2) { visFixEl.textContent = '2D FIX'; visFixEl.className = 'gps-fix-badge fix-2d'; } else { visFixEl.textContent = 'NO FIX'; visFixEl.className = 'gps-fix-badge no-fix'; } } // Visuals: GPS time if (pos.timestamp) { const t = new Date(pos.timestamp); setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')); } } function updateSkyUI(sky) { // Sidebar sat counts setText('gpsSatUsed', sky.usat != null ? sky.usat : '-'); setText('gpsSatTotal', sky.nsat != null ? sky.nsat : '-'); // DOP values setDop('gpsHdop', sky.hdop); setDop('gpsVdop', sky.vdop); setDop('gpsPdop', sky.pdop); setDop('gpsTdop', sky.tdop); setDop('gpsGdop', sky.gdop); // Visuals drawSkyView(sky.satellites || []); drawSignalBars(sky.satellites || []); } function setDop(id, val) { const el = document.getElementById(id); if (!el) return; if (val == null) { el.textContent = '---'; el.className = 'gps-info-value gps-mono'; return; } el.textContent = val.toFixed(1); let cls = 'gps-info-value gps-mono '; if (val <= 2) cls += 'gps-dop-good'; else if (val <= 5) cls += 'gps-dop-moderate'; else cls += 'gps-dop-poor'; el.className = cls; } function setText(id, val) { const el = document.getElementById(id); if (el) el.textContent = val; } // ======================== // Sky View Globe (WebGL with 2D fallback) // ======================== function drawEmptySkyView() { if (!skyRendererInitAttempted) { initSkyRenderer(); } if (skyRenderer) { skyRenderer.setSatellites([]); return; } const canvas = document.getElementById('gpsSkyCanvas'); if (!canvas) return; drawSkyViewBase2D(canvas); } function drawSkyView(satellites) { if (!skyRendererInitAttempted) { initSkyRenderer(); } const sats = Array.isArray(satellites) ? satellites : []; if (skyRenderer) { skyRenderer.setSatellites(sats); return; } const canvas = document.getElementById('gpsSkyCanvas'); if (!canvas) return; drawSkyViewBase2D(canvas); const ctx = canvas.getContext('2d'); if (!ctx) return; const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const r = Math.min(cx, cy) - 24; sats.forEach(sat => { if (sat.elevation == null || sat.azimuth == null) return; const elRad = (90 - sat.elevation) / 90; const azRad = (sat.azimuth - 90) * Math.PI / 180; const px = cx + r * elRad * Math.cos(azRad); const py = cy + r * elRad * Math.sin(azRad); const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS; const dotSize = sat.used ? 6 : 4; ctx.beginPath(); ctx.arc(px, py, dotSize, 0, Math.PI * 2); if (sat.used) { ctx.fillStyle = color; ctx.fill(); } else { ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.fillStyle = color; ctx.font = '8px Roboto Condensed, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(sat.prn, px, py - dotSize - 2); if (sat.snr != null) { ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '7px Roboto Condensed, monospace'; ctx.textBaseline = 'top'; ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1); } }); } function drawSkyViewBase2D(canvas) { const ctx = canvas.getContext('2d'); if (!ctx) return; const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const r = Math.min(cx, cy) - 24; ctx.clearRect(0, 0, w, h); const cs = getComputedStyle(document.documentElement); const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117'; const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040'; const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555'; const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888'; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = gridColor; ctx.lineWidth = 0.5; [90, 60, 30].forEach(el => { const gr = r * (1 - el / 90); ctx.beginPath(); ctx.arc(cx, cy, gr, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = dimColor; ctx.font = '9px Roboto Condensed, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); }); ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = secondaryColor; ctx.font = 'bold 11px Roboto Condensed, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('N', cx, cy - r - 12); ctx.fillText('S', cx, cy + r + 12); ctx.fillText('E', cx + r + 12, cy); ctx.fillText('W', cx - r - 12, cy); ctx.strokeStyle = gridColor; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r); ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy); ctx.stroke(); ctx.fillStyle = dimColor; ctx.beginPath(); ctx.arc(cx, cy, 2, 0, Math.PI * 2); ctx.fill(); } function createWebGlSkyRenderer(canvas, overlay) { const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true }); if (!gl) return null; const lineProgram = createProgram( gl, [ 'attribute vec3 aPosition;', 'uniform mat4 uMVP;', 'void main(void) {', ' gl_Position = uMVP * vec4(aPosition, 1.0);', '}', ].join('\n'), [ 'precision mediump float;', 'uniform vec4 uColor;', 'void main(void) {', ' gl_FragColor = uColor;', '}', ].join('\n'), ); const pointProgram = createProgram( gl, [ 'attribute vec3 aPosition;', 'attribute vec4 aColor;', 'attribute float aSize;', 'attribute float aUsed;', 'uniform mat4 uMVP;', 'uniform float uDevicePixelRatio;', 'uniform vec3 uCameraDir;', 'varying vec4 vColor;', 'varying float vUsed;', 'varying float vFacing;', 'void main(void) {', ' vec3 normPos = normalize(aPosition);', ' vFacing = dot(normPos, normalize(uCameraDir));', ' gl_Position = uMVP * vec4(aPosition, 1.0);', ' gl_PointSize = aSize * uDevicePixelRatio;', ' vColor = aColor;', ' vUsed = aUsed;', '}', ].join('\n'), [ 'precision mediump float;', 'varying vec4 vColor;', 'varying float vUsed;', 'varying float vFacing;', 'void main(void) {', ' if (vFacing <= 0.0) discard;', ' vec2 c = gl_PointCoord * 2.0 - 1.0;', ' float d = dot(c, c);', ' if (d > 1.0) discard;', ' if (vUsed < 0.5 && d < 0.45) discard;', ' float edge = smoothstep(1.0, 0.75, d);', ' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);', '}', ].join('\n'), ); if (!lineProgram || !pointProgram) return null; const lineLoc = { position: gl.getAttribLocation(lineProgram, 'aPosition'), mvp: gl.getUniformLocation(lineProgram, 'uMVP'), color: gl.getUniformLocation(lineProgram, 'uColor'), }; const pointLoc = { position: gl.getAttribLocation(pointProgram, 'aPosition'), color: gl.getAttribLocation(pointProgram, 'aColor'), size: gl.getAttribLocation(pointProgram, 'aSize'), used: gl.getAttribLocation(pointProgram, 'aUsed'), mvp: gl.getUniformLocation(pointProgram, 'uMVP'), dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'), cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'), }; const gridVertices = buildSkyGridVertices(); const horizonVertices = buildSkyRingVertices(0, 4); const gridBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW); const horizonBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer); gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW); const satPosBuffer = gl.createBuffer(); const satColorBuffer = gl.createBuffer(); const satSizeBuffer = gl.createBuffer(); const satUsedBuffer = gl.createBuffer(); let satCount = 0; let satLabels = []; let cssWidth = 0; let cssHeight = 0; let devicePixelRatio = 1; let mvpMatrix = identityMat4(); let cameraDir = [0, 1, 0]; let yaw = 0.8; let pitch = 0.6; let distance = 2.7; let rafId = null; let destroyed = false; let activePointerId = null; let lastPointerX = 0; let lastPointerY = 0; const resizeObserver = (typeof ResizeObserver !== 'undefined') ? new ResizeObserver(() => { requestRender(); }) : null; if (resizeObserver) resizeObserver.observe(canvas); canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointercancel', onPointerUp); canvas.addEventListener('wheel', onWheel, { passive: false }); requestRender(); function onPointerDown(evt) { activePointerId = evt.pointerId; lastPointerX = evt.clientX; lastPointerY = evt.clientY; if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId); } function onPointerMove(evt) { if (activePointerId == null || evt.pointerId !== activePointerId) return; const dx = evt.clientX - lastPointerX; const dy = evt.clientY - lastPointerY; lastPointerX = evt.clientX; lastPointerY = evt.clientY; yaw += dx * 0.01; pitch += dy * 0.01; pitch = Math.max(0.1, Math.min(1.45, pitch)); requestRender(); } function onPointerUp(evt) { if (activePointerId == null || evt.pointerId !== activePointerId) return; if (canvas.releasePointerCapture) { try { canvas.releasePointerCapture(evt.pointerId); } catch (_) {} } activePointerId = null; } function onWheel(evt) { evt.preventDefault(); distance += evt.deltaY * 0.002; distance = Math.max(2.0, Math.min(5.0, distance)); requestRender(); } function setSatellites(satellites) { const positions = []; const colors = []; const sizes = []; const usedFlags = []; const labels = []; (satellites || []).forEach(sat => { if (sat.elevation == null || sat.azimuth == null) return; const xyz = skyToCartesian(sat.azimuth, sat.elevation); const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS; const rgb = hexToRgb01(hex); positions.push(xyz[0], xyz[1], xyz[2]); colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85); sizes.push(sat.used ? 8 : 7); usedFlags.push(sat.used ? 1 : 0); labels.push({ text: String(sat.prn), point: xyz, color: hex, used: !!sat.used, }); }); satLabels = labels; satCount = positions.length / 3; gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW); requestRender(); } function requestRender() { if (destroyed || rafId != null) return; rafId = requestAnimationFrame(render); } function render() { rafId = null; if (destroyed) return; resizeCanvas(); updateCameraMatrices(); const palette = getThemePalette(); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.useProgram(lineProgram); gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix); gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); gl.enableVertexAttribArray(lineLoc.position); gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0); gl.uniform4fv(lineLoc.color, palette.grid); gl.drawArrays(gl.LINES, 0, gridVertices.length / 3); gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer); gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0); gl.uniform4fv(lineLoc.color, palette.horizon); gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3); if (satCount > 0) { gl.useProgram(pointProgram); gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix); gl.uniform1f(pointLoc.dpr, devicePixelRatio); gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir)); gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer); gl.enableVertexAttribArray(pointLoc.position); gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer); gl.enableVertexAttribArray(pointLoc.color); gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer); gl.enableVertexAttribArray(pointLoc.size); gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer); gl.enableVertexAttribArray(pointLoc.used); gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.POINTS, 0, satCount); } drawOverlayLabels(); } function resizeCanvas() { cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400)); cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400)); devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2); const renderWidth = Math.floor(cssWidth * devicePixelRatio); const renderHeight = Math.floor(cssHeight * devicePixelRatio); if (canvas.width !== renderWidth || canvas.height !== renderHeight) { canvas.width = renderWidth; canvas.height = renderHeight; } } function updateCameraMatrices() { const cosPitch = Math.cos(pitch); const eye = [ distance * Math.sin(yaw) * cosPitch, distance * Math.sin(pitch), distance * Math.cos(yaw) * cosPitch, ]; const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1; cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen]; const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]); const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20); mvpMatrix = mat4Multiply(proj, view); } function drawOverlayLabels() { if (!overlay) return; const fragment = document.createDocumentFragment(); const cardinals = [ { text: 'N', point: [0, 0, 1] }, { text: 'E', point: [1, 0, 0] }, { text: 'S', point: [0, 0, -1] }, { text: 'W', point: [-1, 0, 0] }, { text: 'Z', point: [0, 1, 0] }, ]; cardinals.forEach(entry => { addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal'); }); satLabels.forEach(sat => { const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused'); addLabel(fragment, sat.text, sat.point, cls, sat.color); }); overlay.replaceChildren(fragment); } function addLabel(fragment, text, point, className, color) { const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2]; if (facing <= 0.02) return; const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight); if (!projected) return; const label = document.createElement('span'); label.className = className; label.textContent = text; label.style.left = projected.x.toFixed(1) + 'px'; label.style.top = projected.y.toFixed(1) + 'px'; if (color) label.style.color = color; fragment.appendChild(label); } function getThemePalette() { const cs = getComputedStyle(document.documentElement); const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117'); const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254'); const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff'); return { bg: bg, grid: [grid[0], grid[1], grid[2], 0.42], horizon: [accent[0], accent[1], accent[2], 0.56], }; } function destroy() { destroyed = true; if (rafId != null) cancelAnimationFrame(rafId); canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); canvas.removeEventListener('pointercancel', onPointerUp); canvas.removeEventListener('wheel', onWheel); if (resizeObserver) { try { resizeObserver.disconnect(); } catch (_) {} } if (overlay) overlay.replaceChildren(); } return { setSatellites: setSatellites, requestRender: requestRender, destroy: destroy, }; } function buildSkyGridVertices() { const vertices = []; [15, 30, 45, 60, 75].forEach(el => { appendLineStrip(vertices, buildRingPoints(el, 6)); }); for (let az = 0; az < 360; az += 30) { appendLineStrip(vertices, buildMeridianPoints(az, 5)); } return new Float32Array(vertices); } function buildSkyRingVertices(elevation, stepAz) { const vertices = []; appendLineStrip(vertices, buildRingPoints(elevation, stepAz)); return new Float32Array(vertices); } function buildRingPoints(elevation, stepAz) { const points = []; for (let az = 0; az <= 360; az += stepAz) { points.push(skyToCartesian(az, elevation)); } return points; } function buildMeridianPoints(azimuth, stepEl) { const points = []; for (let el = 0; el <= 90; el += stepEl) { points.push(skyToCartesian(azimuth, el)); } return points; } function appendLineStrip(target, points) { for (let i = 1; i < points.length; i += 1) { const a = points[i - 1]; const b = points[i]; target.push(a[0], a[1], a[2], b[0], b[1], b[2]); } } function skyToCartesian(azimuthDeg, elevationDeg) { const az = degToRad(azimuthDeg); const el = degToRad(elevationDeg); const cosEl = Math.cos(el); return [ cosEl * Math.sin(az), Math.sin(el), cosEl * Math.cos(az), ]; } function degToRad(deg) { return deg * Math.PI / 180; } function createProgram(gl, vertexSource, fragmentSource) { const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); if (!vertexShader || !fragmentShader) return null; const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.warn('WebGL program link failed:', gl.getProgramInfoLog(program)); gl.deleteProgram(program); return null; } return program; } function compileShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } function identityMat4() { return new Float32Array([ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ]); } function mat4Perspective(fovy, aspect, near, far) { const f = 1 / Math.tan(fovy / 2); const nf = 1 / (near - far); return new Float32Array([ f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, (2 * far * near) * nf, 0, ]); } function mat4LookAt(eye, center, up) { const zx = eye[0] - center[0]; const zy = eye[1] - center[1]; const zz = eye[2] - center[2]; const zLen = Math.hypot(zx, zy, zz) || 1; const znx = zx / zLen; const zny = zy / zLen; const znz = zz / zLen; const xx = up[1] * znz - up[2] * zny; const xy = up[2] * znx - up[0] * znz; const xz = up[0] * zny - up[1] * znx; const xLen = Math.hypot(xx, xy, xz) || 1; const xnx = xx / xLen; const xny = xy / xLen; const xnz = xz / xLen; const ynx = zny * xnz - znz * xny; const yny = znz * xnx - znx * xnz; const ynz = znx * xny - zny * xnx; return new Float32Array([ xnx, ynx, znx, 0, xny, yny, zny, 0, xnz, ynz, znz, 0, -(xnx * eye[0] + xny * eye[1] + xnz * eye[2]), -(ynx * eye[0] + yny * eye[1] + ynz * eye[2]), -(znx * eye[0] + zny * eye[1] + znz * eye[2]), 1, ]); } function mat4Multiply(a, b) { const out = new Float32Array(16); for (let col = 0; col < 4; col += 1) { for (let row = 0; row < 4; row += 1) { out[col * 4 + row] = a[row] * b[col * 4] + a[4 + row] * b[col * 4 + 1] + a[8 + row] * b[col * 4 + 2] + a[12 + row] * b[col * 4 + 3]; } } return out; } function projectPoint(point, matrix, width, height) { const x = point[0]; const y = point[1]; const z = point[2]; const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]; const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]; const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; if (clipW <= 0.0001) return null; const ndcX = clipX / clipW; const ndcY = clipY / clipW; if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null; return { x: (ndcX * 0.5 + 0.5) * width, y: (1 - (ndcY * 0.5 + 0.5)) * height, }; } function parseCssColor(raw, fallbackHex) { const value = (raw || '').trim(); if (value.startsWith('#')) { return hexToRgb01(value); } const match = value.match(/rgba?\(([^)]+)\)/i); if (match) { const parts = match[1].split(',').map(part => parseFloat(part.trim())); if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) { return [parts[0] / 255, parts[1] / 255, parts[2] / 255]; } } return hexToRgb01(fallbackHex || '#0d1117'); } function hexToRgb01(hex) { let clean = (hex || '').trim().replace('#', ''); if (clean.length === 3) { clean = clean.split('').map(ch => ch + ch).join(''); } if (!/^[0-9a-fA-F]{6}$/.test(clean)) { return [0, 0, 0]; } const num = parseInt(clean, 16); return [ ((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255, ]; } // ======================== // Signal Strength Bars // ======================== function drawSignalBars(satellites) { const container = document.getElementById('gpsSignalBars'); if (!container) return; container.innerHTML = ''; if (satellites.length === 0) return; // Sort: used first, then by PRN const sorted = [...satellites].sort((a, b) => { if (a.used !== b.used) return a.used ? -1 : 1; return a.prn - b.prn; }); const maxSnr = 50; // dB-Hz typical max for display sorted.forEach(sat => { const snr = sat.snr || 0; const heightPct = Math.min(snr / maxSnr * 100, 100); const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS']; const constClass = 'gps-const-' + (sat.constellation || 'GPS').toLowerCase(); const wrap = document.createElement('div'); wrap.className = 'gps-signal-bar-wrap'; const snrLabel = document.createElement('span'); snrLabel.className = 'gps-signal-snr'; snrLabel.textContent = snr > 0 ? Math.round(snr) : ''; const bar = document.createElement('div'); bar.className = 'gps-signal-bar ' + constClass + (sat.used ? '' : ' unused'); bar.style.height = Math.max(heightPct, 2) + '%'; bar.title = `PRN ${sat.prn} (${sat.constellation}) - ${Math.round(snr)} dB-Hz${sat.used ? ' [USED]' : ''}`; const prn = document.createElement('span'); prn.className = 'gps-signal-prn'; prn.textContent = sat.prn; wrap.appendChild(snrLabel); wrap.appendChild(bar); wrap.appendChild(prn); container.appendChild(wrap); }); } // ======================== // Cleanup // ======================== function destroy() { unsubscribeFromStream(); stopSkyPolling(); if (themeObserver) { themeObserver.disconnect(); themeObserver = null; } if (skyRenderer) { skyRenderer.destroy(); skyRenderer = null; } skyRendererInitAttempted = false; } return { init: init, connect: connect, disconnect: disconnect, destroy: destroy, }; })();