mirror of
https://github.com/smittix/intercept.git
synced 2026-07-01 14:28:59 -07:00
Merge upstream/main and resolve weather-satellite.js conflict
Resolved conflict in static/js/modes/weather-satellite.js: - Kept allPasses state variable and applyPassFilter() for satellite pass filtering - Kept satellite select dropdown listener for filter feature - Adopted upstream's optimistic stop() UI pattern for better responsiveness - Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -202,10 +202,38 @@ body {
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
z-index: 1;
|
||||
animation: welcomeFadeIn 0.8s ease-out;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-settings-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim, rgba(255, 255, 255, 0.3));
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.welcome-settings-btn:hover {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.welcome-settings-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes welcomeFadeIn {
|
||||
@@ -232,6 +260,7 @@ body {
|
||||
|
||||
.welcome-logo {
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
@@ -332,6 +361,7 @@ body {
|
||||
padding: 20px;
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
@@ -1559,6 +1589,7 @@ header h1 .tagline {
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
@@ -1744,6 +1775,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent-green);
|
||||
@@ -1781,6 +1813,7 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent-red);
|
||||
|
||||
@@ -151,8 +151,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gps-sky-globe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
@@ -166,10 +175,50 @@
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas,
|
||||
.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon {
|
||||
--sat-size: 18px;
|
||||
--sat-color: #8ea6bd;
|
||||
width: var(--sat-size);
|
||||
height: var(--sat-size);
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--sat-color);
|
||||
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.22), rgba(7, 14, 23, 0.82) 72%);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon img {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.used {
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.gps-globe-sat-icon.unused {
|
||||
opacity: 0.72;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 6px var(--sat-color);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Morse Code / CW Decoder Styles */
|
||||
|
||||
/* Scope canvas container */
|
||||
.morse-scope-container {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.morse-scope-container canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Decoded text panel */
|
||||
.morse-decoded-panel {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.morse-decoded-panel:empty::before {
|
||||
content: 'Decoded text will appear here...';
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Individual decoded character with fade-in */
|
||||
.morse-char {
|
||||
display: inline;
|
||||
animation: morseFadeIn 0.3s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes morseFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Morse notation above character */
|
||||
.morse-char-morse {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 1px;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
/* Reference grid */
|
||||
.morse-ref-grid {
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.morse-ref-grid.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Toolbar: export/copy/clear */
|
||||
.morse-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-toolbar .btn {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Status bar at bottom */
|
||||
.morse-status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.morse-status-bar .status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Word space styling */
|
||||
.morse-word-space {
|
||||
display: inline;
|
||||
width: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
/* ============================================
|
||||
WeFax (Weather Fax) Mode Styles
|
||||
Amber/gold theme (#ffaa00) for HF
|
||||
============================================ */
|
||||
|
||||
/* Place WeFax sidebar panel above the shared SDR Device section
|
||||
while keeping the collapse button at the very top. */
|
||||
#wefaxMode.active {
|
||||
order: -1;
|
||||
}
|
||||
.sidebar:has(#wefaxMode.active) > .sidebar-collapse-btn {
|
||||
order: -2;
|
||||
}
|
||||
|
||||
/* --- Stats Strip --- */
|
||||
.wefax-stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wefax-strip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wefax-strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wefax-strip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; }
|
||||
.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; }
|
||||
.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; }
|
||||
.wefax-strip-dot.complete { background: #00cc66; }
|
||||
.wefax-strip-dot.error { background: #f44; }
|
||||
|
||||
@keyframes wefax-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.wefax-strip-status-text {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wefax-strip-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-primary, #161b22);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; }
|
||||
.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; }
|
||||
.wefax-strip-btn.start.wefax-strip-btn-error {
|
||||
border-color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-pulse 0.6s ease-in-out 3;
|
||||
}
|
||||
|
||||
.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; }
|
||||
.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; }
|
||||
|
||||
.wefax-strip-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.wefax-strip-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wefax-strip-value.accent-amber { color: #ffaa00; }
|
||||
|
||||
.wefax-strip-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* --- Schedule Toggle --- */
|
||||
.wefax-schedule-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wefax-schedule-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-toggle input:checked + span {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
/* --- Visuals Container --- */
|
||||
.wefax-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Main Row --- */
|
||||
.wefax-main-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Schedule Timeline --- */
|
||||
.wefax-schedule-panel {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-schedule-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-schedule-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a)11;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry:last-child { border-bottom: none; }
|
||||
|
||||
.wefax-schedule-entry.active {
|
||||
background: #ffaa0010;
|
||||
border-left: 3px solid #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry.upcoming {
|
||||
background: #ffaa0008;
|
||||
}
|
||||
|
||||
.wefax-schedule-entry.past {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.wefax-schedule-time {
|
||||
color: #ffaa00;
|
||||
min-width: 45px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wefax-schedule-content {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wefax-schedule-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border-color, #1e2a3a);
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-schedule-badge.live {
|
||||
background: #ffaa0030;
|
||||
color: #ffaa00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wefax-schedule-badge.soon {
|
||||
background: #ffaa0015;
|
||||
color: #ffcc66;
|
||||
}
|
||||
|
||||
.wefax-schedule-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* --- Live Section --- */
|
||||
.wefax-live-section {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-live-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-live-content {
|
||||
padding: 12px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wefax-idle-state {
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-idle-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ffaa0033;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wefax-idle-state h4 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wefax-idle-state p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wefax-live-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 4px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* --- Gallery Section --- */
|
||||
.wefax-gallery-section {
|
||||
background: var(--bg-card, #0e1117);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2a3a);
|
||||
}
|
||||
|
||||
.wefax-gallery-title {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-gallery-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wefax-gallery-count {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-gallery-clear-btn {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
color: var(--text-dim, #555);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wefax-gallery-clear-btn:hover {
|
||||
border-color: #f44;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.wefax-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wefax-gallery-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #555);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wefax-gallery-item {
|
||||
position: relative;
|
||||
background: var(--bg-primary, #161b22);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wefax-gallery-item img:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.wefax-gallery-meta {
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.wefax-gallery-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.wefax-gallery-item:hover .wefax-gallery-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wefax-gallery-action {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wefax-gallery-action:hover { color: #fff; }
|
||||
.wefax-gallery-action.delete:hover { color: #f44; }
|
||||
|
||||
/* --- Countdown Bar + Timeline --- */
|
||||
.wefax-countdown-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary, #141820);
|
||||
border: 1px solid var(--border-color, #1e2a3a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wefax-countdown-next {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wefax-countdown-boxes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wefax-countdown-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.wefax-countdown-box.imminent {
|
||||
border-color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.wefax-countdown-box.active {
|
||||
border-color: #ffaa00;
|
||||
box-shadow: 0 0 8px rgba(255, 170, 0, 0.3);
|
||||
animation: wefax-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wefax-glow {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); }
|
||||
50% { box-shadow: 0 0 16px rgba(255, 170, 0, 0.5); }
|
||||
}
|
||||
|
||||
.wefax-cd-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wefax-cd-unit {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wefax-countdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wefax-countdown-content {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wefax-countdown-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wefax-timeline {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 36px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.wefax-timeline-track {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 170, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wefax-timeline-broadcast.active {
|
||||
background: rgba(255, 170, 0, 0.85);
|
||||
border: 1px solid #ffaa00;
|
||||
}
|
||||
|
||||
.wefax-timeline-cursor {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #ff4444;
|
||||
border-radius: 1px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.wefax-timeline-labels {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* --- Image Modal --- */
|
||||
.wefax-image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.wefax-image-modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wefax-image-modal img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wefax-modal-toolbar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 60px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wefax-modal-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wefax-modal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.wefax-modal-btn.delete:hover {
|
||||
background: var(--accent-red, #ff3366);
|
||||
border-color: var(--accent-red, #ff3366);
|
||||
}
|
||||
|
||||
.wefax-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wefax-modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 768px) {
|
||||
.wefax-main-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Satellite</title>
|
||||
<desc id="desc">Professional satellite icon with solar panels and body</desc>
|
||||
<defs>
|
||||
<linearGradient id="panelGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#344a5f"/>
|
||||
<stop offset="55%" stop-color="#233547"/>
|
||||
<stop offset="100%" stop-color="#1a2734"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelGrid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4f6a85" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#2b3f53" stop-opacity="0.1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bodyGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8ea0b2"/>
|
||||
<stop offset="45%" stop-color="#6f8193"/>
|
||||
<stop offset="100%" stop-color="#536475"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="dishGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#7e91a4"/>
|
||||
<stop offset="100%" stop-color="#556779"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="8" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
|
||||
<rect x="12" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
|
||||
<path d="M21 46v36M30 46v36M12 55h30M12 64h30M12 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
|
||||
|
||||
<rect x="82" y="42" width="38" height="44" rx="2.5" fill="url(#panelGradient)" stroke="#607891" stroke-width="2"/>
|
||||
<rect x="86" y="46" width="30" height="36" rx="1.5" fill="url(#panelGrid)" stroke="#405669" stroke-width="1.2"/>
|
||||
<path d="M95 46v36M104 46v36M86 55h30M86 64h30M86 73h30" stroke="#4a6278" stroke-width="0.9" opacity="0.82"/>
|
||||
|
||||
<line x1="46" y1="64" x2="52" y2="64" stroke="#8fa4b9" stroke-width="3"/>
|
||||
<line x1="76" y1="64" x2="82" y2="64" stroke="#8fa4b9" stroke-width="3"/>
|
||||
|
||||
<rect x="52" y="40" width="24" height="48" rx="4" fill="url(#bodyGradient)" stroke="#91a5b8" stroke-width="2"/>
|
||||
<rect x="55" y="53" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.48"/>
|
||||
<rect x="55" y="62" width="18" height="4" rx="1.5" fill="#d2dce6" opacity="0.42"/>
|
||||
|
||||
<path d="M64 24c6 0 11 5 11 11s-5 11-11 11-11-5-11-11 5-11 11-11Z" fill="url(#dishGradient)" stroke="#95aac0" stroke-width="2"/>
|
||||
<circle cx="64" cy="35" r="3.2" fill="#d7e2ed" opacity="0.7"/>
|
||||
<path d="M58 26c2.2-2.4 9.8-2.4 12 0" fill="none" stroke="#a7b8c8" stroke-width="1.5"/>
|
||||
<line x1="64" y1="46" x2="64" y2="51" stroke="#9fb2c6" stroke-width="2"/>
|
||||
|
||||
<path d="M57 88L64 101L71 88Z" fill="url(#dishGradient)" stroke="#8fa4b8" stroke-width="1.8"/>
|
||||
<line x1="64" y1="101" x2="64" y2="108" stroke="#8fa4b8" stroke-width="2"/>
|
||||
<circle cx="64" cy="110" r="2.8" fill="#b9c9d8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -7,6 +7,7 @@ const AlertCenter = (function() {
|
||||
let rules = [];
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let lastConnectionWarningAt = 0;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
@@ -31,7 +32,14 @@ const AlertCenter = (function() {
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[Alerts] SSE connection error');
|
||||
const now = Date.now();
|
||||
const offline = (typeof window.isOffline === 'function' && window.isOffline()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
const shouldLog = !offline && !document.hidden && (now - lastConnectionWarningAt) > 15000;
|
||||
if (shouldLog) {
|
||||
lastConnectionWarningAt = now;
|
||||
console.warn('[Alerts] SSE connection error; retrying');
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
|
||||
@@ -92,8 +92,9 @@ const RunState = (function() {
|
||||
renderHealth(data);
|
||||
} catch (err) {
|
||||
renderHealth(null, err);
|
||||
const transient = isTransientFailure(err);
|
||||
const now = Date.now();
|
||||
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
if (!transient && typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
lastErrorToastAt = now;
|
||||
reportActionableError('Run State', err, { persistent: false });
|
||||
}
|
||||
@@ -214,6 +215,17 @@ const RunState = (function() {
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function isTransientFailure(err) {
|
||||
if (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
return true;
|
||||
}
|
||||
const text = extractMessage(err).toLowerCase();
|
||||
return text.includes('failed to fetch') || text.includes('network') || text.includes('timeout');
|
||||
}
|
||||
|
||||
function getLastHealth() {
|
||||
return lastHealth;
|
||||
}
|
||||
|
||||
@@ -1281,6 +1281,7 @@ function loadVoiceAlertConfig() {
|
||||
const pager = document.getElementById('voiceCfgPager');
|
||||
const tscm = document.getElementById('voiceCfgTscm');
|
||||
const tracker = document.getElementById('voiceCfgTracker');
|
||||
const military = document.getElementById('voiceCfgAdsbMilitary');
|
||||
const squawk = document.getElementById('voiceCfgSquawk');
|
||||
const rate = document.getElementById('voiceCfgRate');
|
||||
const pitch = document.getElementById('voiceCfgPitch');
|
||||
@@ -1290,6 +1291,7 @@ function loadVoiceAlertConfig() {
|
||||
if (pager) pager.checked = cfg.streams.pager !== false;
|
||||
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
||||
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
||||
if (military) military.checked = cfg.streams.adsb_military !== false;
|
||||
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||
if (rate) rate.value = cfg.rate;
|
||||
if (pitch) pitch.value = cfg.pitch;
|
||||
@@ -1314,10 +1316,11 @@ function saveVoiceAlertConfig() {
|
||||
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
||||
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
||||
streams: {
|
||||
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||
adsb_military: !!document.getElementById('voiceCfgAdsbMilitary')?.checked,
|
||||
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,9 +208,31 @@ const AppFeedback = (function() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function isOffline() {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function isTransientNetworkError(error) {
|
||||
const text = String(extractMessage(error) || '').toLowerCase();
|
||||
if (!text) return false;
|
||||
|
||||
return text.includes('networkerror') ||
|
||||
text.includes('failed to fetch') ||
|
||||
text.includes('network request failed') ||
|
||||
text.includes('load failed') ||
|
||||
text.includes('err_network_io_suspended') ||
|
||||
text.includes('network io suspended') ||
|
||||
text.includes('the network connection was lost') ||
|
||||
text.includes('connection reset') ||
|
||||
text.includes('timeout');
|
||||
}
|
||||
|
||||
function isTransientOrOffline(error) {
|
||||
return isOffline() || isTransientNetworkError(error);
|
||||
}
|
||||
|
||||
function isNetworkError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||
return isTransientNetworkError(message);
|
||||
}
|
||||
|
||||
function isSettingsError(message) {
|
||||
@@ -224,6 +246,9 @@ const AppFeedback = (function() {
|
||||
reportError,
|
||||
removeToast,
|
||||
renderCollectionState,
|
||||
isOffline,
|
||||
isTransientNetworkError,
|
||||
isTransientOrOffline,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -243,6 +268,18 @@ window.renderCollectionState = function(container, options) {
|
||||
return AppFeedback.renderCollectionState(container, options);
|
||||
};
|
||||
|
||||
window.isOffline = function() {
|
||||
return AppFeedback.isOffline();
|
||||
};
|
||||
|
||||
window.isTransientNetworkError = function(error) {
|
||||
return AppFeedback.isTransientNetworkError(error);
|
||||
};
|
||||
|
||||
window.isTransientOrOffline = function(error) {
|
||||
return AppFeedback.isTransientOrOffline(error);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
AppFeedback.init();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,13 @@ const VoiceAlerts = (function () {
|
||||
rate: 1.1,
|
||||
pitch: 0.9,
|
||||
voiceName: '',
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
streams: {
|
||||
pager: true,
|
||||
tscm: true,
|
||||
bluetooth: true,
|
||||
adsb_military: true,
|
||||
squawks: true,
|
||||
},
|
||||
};
|
||||
|
||||
function _toNumberInRange(value, fallback, min, max) {
|
||||
|
||||
+530
-68
@@ -9,22 +9,45 @@ const GPS = (function() {
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
let skyPollTimer = null;
|
||||
let statusPollTimer = null;
|
||||
let themeObserver = null;
|
||||
let skyRenderer = null;
|
||||
let skyRendererInitAttempted = false;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
let skyRendererInitPromise = null;
|
||||
|
||||
// Constellation color map
|
||||
const CONST_COLORS = {
|
||||
'GPS': '#00d4ff',
|
||||
'GLONASS': '#00ff88',
|
||||
'Galileo': '#ff8800',
|
||||
'BeiDou': '#ff4466',
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
'SBAS': '#ffdd00',
|
||||
'QZSS': '#cc66ff',
|
||||
};
|
||||
|
||||
const CONST_ALTITUDES = {
|
||||
'GPS': 0.28,
|
||||
'GLONASS': 0.27,
|
||||
'Galileo': 0.29,
|
||||
'BeiDou': 0.30,
|
||||
'SBAS': 0.34,
|
||||
'QZSS': 0.31,
|
||||
};
|
||||
|
||||
const GPS_GLOBE_SCRIPT_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||
];
|
||||
const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||
const GPS_SATELLITE_ICON_URL = '/static/images/globe/satellite-icon.svg';
|
||||
|
||||
function init() {
|
||||
initSkyRenderer();
|
||||
const initPromise = initSkyRenderer();
|
||||
if (initPromise && typeof initPromise.then === 'function') {
|
||||
initPromise.then(() => {
|
||||
if (lastSky) drawSkyView(lastSky.satellites || []);
|
||||
else drawEmptySkyView();
|
||||
}).catch(() => {});
|
||||
}
|
||||
drawEmptySkyView();
|
||||
if (!connected) connect();
|
||||
|
||||
@@ -48,26 +71,397 @@ const GPS = (function() {
|
||||
}
|
||||
|
||||
function initSkyRenderer() {
|
||||
if (skyRendererInitAttempted) return;
|
||||
if (skyRendererInitPromise) return skyRendererInitPromise;
|
||||
skyRendererInitAttempted = true;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
let fallbackRenderer = null;
|
||||
const fallbackCanvas = document.getElementById('gpsSkyCanvas');
|
||||
const fallbackOverlay = document.getElementById('gpsSkyOverlay');
|
||||
|
||||
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);
|
||||
// Show an immediate fallback while the globe library loads.
|
||||
setSkyCanvasFallbackMode(true);
|
||||
if (fallbackCanvas) {
|
||||
try {
|
||||
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
|
||||
skyRenderer = fallbackRenderer;
|
||||
} catch (err) {
|
||||
fallbackRenderer = null;
|
||||
skyRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
skyRendererInitPromise = (async function() {
|
||||
const globeContainer = document.getElementById('gpsSkyGlobe');
|
||||
if (globeContainer) {
|
||||
try {
|
||||
const globeRenderer = await createGlobeSkyRenderer(globeContainer);
|
||||
if (globeRenderer) {
|
||||
if (fallbackRenderer && fallbackRenderer !== globeRenderer && typeof fallbackRenderer.destroy === 'function') {
|
||||
fallbackRenderer.destroy();
|
||||
}
|
||||
setSkyCanvasFallbackMode(false);
|
||||
skyRenderer = globeRenderer;
|
||||
return skyRenderer;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('GPS globe renderer failed, falling back to canvas renderer', err);
|
||||
}
|
||||
}
|
||||
|
||||
setSkyCanvasFallbackMode(true);
|
||||
if (!fallbackRenderer && fallbackCanvas) {
|
||||
try {
|
||||
fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay);
|
||||
} catch (err) {
|
||||
fallbackRenderer = null;
|
||||
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||
}
|
||||
}
|
||||
|
||||
skyRenderer = fallbackRenderer;
|
||||
return skyRenderer;
|
||||
})();
|
||||
|
||||
return skyRendererInitPromise;
|
||||
}
|
||||
|
||||
function setSkyCanvasFallbackMode(enabled) {
|
||||
const wrap = document.getElementById('gpsSkyViewWrap');
|
||||
if (wrap) {
|
||||
wrap.classList.toggle('gps-sky-fallback', !!enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
function isSkyCanvasFallbackEnabled() {
|
||||
const wrap = document.getElementById('gpsSkyViewWrap');
|
||||
return !wrap || wrap.classList.contains('gps-sky-fallback');
|
||||
}
|
||||
|
||||
function getObserverCoords() {
|
||||
const posLat = Number(lastPosition && lastPosition.latitude);
|
||||
const posLon = Number(lastPosition && lastPosition.longitude);
|
||||
if (Number.isFinite(posLat) && Number.isFinite(posLon)) {
|
||||
return { lat: posLat, lon: normalizeLon(posLon) };
|
||||
}
|
||||
|
||||
if (typeof observerLocation === 'object' && observerLocation) {
|
||||
const obsLat = Number(observerLocation.lat);
|
||||
const obsLon = Number(observerLocation.lon);
|
||||
if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) {
|
||||
return { lat: obsLat, lon: normalizeLon(obsLon) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureGpsGlobeLibrary() {
|
||||
if (typeof window.Globe === 'function') return true;
|
||||
|
||||
const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck;
|
||||
if (!webglSupportFn()) return false;
|
||||
|
||||
if (typeof ensureWebsdrGlobeLibrary === 'function') {
|
||||
try {
|
||||
const ready = await ensureWebsdrGlobeLibrary();
|
||||
if (ready && typeof window.Globe === 'function') return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (const src of GPS_GLOBE_SCRIPT_URLS) {
|
||||
await loadGpsGlobeScript(src);
|
||||
}
|
||||
return typeof window.Globe === 'function';
|
||||
}
|
||||
|
||||
function loadGpsGlobeScript(src) {
|
||||
const state = getSharedGlobeScriptState();
|
||||
if (!state.promises[src]) {
|
||||
state.promises[src] = loadSharedGlobeScript(src);
|
||||
}
|
||||
return state.promises[src].catch((error) => {
|
||||
delete state.promises[src];
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function getSharedGlobeScriptState() {
|
||||
const key = '__interceptGlobeScriptState';
|
||||
if (!window[key]) {
|
||||
window[key] = {
|
||||
promises: Object.create(null),
|
||||
};
|
||||
}
|
||||
return window[key];
|
||||
}
|
||||
|
||||
function loadSharedGlobeScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const selector = [
|
||||
`script[data-intercept-globe-src="${src}"]`,
|
||||
`script[data-websdr-src="${src}"]`,
|
||||
`script[data-gps-globe-src="${src}"]`,
|
||||
`script[src="${src}"]`,
|
||||
].join(', ');
|
||||
const existing = document.querySelector(selector);
|
||||
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (existing.dataset.failed === 'true') {
|
||||
existing.remove();
|
||||
} else {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.interceptGlobeSrc = src;
|
||||
script.dataset.gpsGlobeSrc = src;
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.dataset.failed = 'true';
|
||||
reject(new Error(`Failed to load ${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function localWebglSupportCheck() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createGlobeSkyRenderer(container) {
|
||||
const ready = await ensureGpsGlobeLibrary();
|
||||
if (!ready || typeof window.Globe !== 'function') return null;
|
||||
|
||||
let layoutAttempts = 0;
|
||||
while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
layoutAttempts += 1;
|
||||
}
|
||||
if (!container.clientWidth || !container.clientHeight) return null;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)';
|
||||
container.style.cursor = 'grab';
|
||||
|
||||
const globe = window.Globe()(container)
|
||||
.backgroundColor('rgba(0,0,0,0)')
|
||||
.globeImageUrl(GPS_GLOBE_TEXTURE_URL)
|
||||
.showAtmosphere(true)
|
||||
.atmosphereColor('#3bb9ff')
|
||||
.atmosphereAltitude(0.17)
|
||||
.pointRadius('radius')
|
||||
.pointAltitude('altitude')
|
||||
.pointColor('color')
|
||||
.pointLabel(point => point.label || '')
|
||||
.pointsTransitionDuration(0)
|
||||
.htmlAltitude('altitude')
|
||||
.htmlElementsData([])
|
||||
.htmlElement((sat) => createSatelliteIconElement(sat));
|
||||
|
||||
const controls = globe.controls();
|
||||
if (controls) {
|
||||
controls.autoRotate = false;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = 130;
|
||||
controls.maxDistance = 420;
|
||||
controls.rotateSpeed = 0.8;
|
||||
controls.zoomSpeed = 0.8;
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
let lastSatellites = [];
|
||||
let hasInitialView = false;
|
||||
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||
? new ResizeObserver(() => resizeGlobe())
|
||||
: null;
|
||||
|
||||
if (resizeObserver) resizeObserver.observe(container);
|
||||
|
||||
function resizeGlobe() {
|
||||
if (destroyed) return;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (!width || !height) return;
|
||||
globe.width(width);
|
||||
globe.height(height);
|
||||
}
|
||||
|
||||
function renderGlobe() {
|
||||
if (destroyed) return;
|
||||
resizeGlobe();
|
||||
|
||||
const observer = getObserverCoords();
|
||||
const points = [];
|
||||
const satelliteIcons = [];
|
||||
|
||||
if (observer) {
|
||||
points.push({
|
||||
lat: observer.lat,
|
||||
lng: observer.lon,
|
||||
altitude: 0.012,
|
||||
radius: 0.34,
|
||||
color: '#ffffff',
|
||||
label: '<div style="padding:4px 6px; font-size:11px; background:rgba(5,13,20,0.92); border:1px solid rgba(255,255,255,0.28); border-radius:4px;">Observer</div>',
|
||||
});
|
||||
}
|
||||
|
||||
lastSatellites.forEach((sat) => {
|
||||
const azimuth = Number(sat.azimuth);
|
||||
const elevation = Number(sat.elevation);
|
||||
if (!observer || !Number.isFinite(azimuth) || !Number.isFinite(elevation)) return;
|
||||
|
||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||
const shellAltitude = getSatelliteShellAltitude(sat.constellation, elevation);
|
||||
const footprint = projectSkyTrackToEarth(observer.lat, observer.lon, azimuth, elevation);
|
||||
satelliteIcons.push({
|
||||
lat: footprint.lat,
|
||||
lng: footprint.lon,
|
||||
altitude: shellAltitude,
|
||||
color: color,
|
||||
used: !!sat.used,
|
||||
sizePx: sat.used ? 20 : 17,
|
||||
title: buildSatelliteTitle(sat),
|
||||
iconUrl: GPS_SATELLITE_ICON_URL,
|
||||
});
|
||||
});
|
||||
|
||||
globe.pointsData(points);
|
||||
globe.htmlElementsData(satelliteIcons);
|
||||
|
||||
if (observer && !hasInitialView) {
|
||||
globe.pointOfView({ lat: observer.lat, lng: observer.lon, altitude: 1.6 }, 950);
|
||||
hasInitialView = true;
|
||||
}
|
||||
}
|
||||
|
||||
function createSatelliteIconElement(sat) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = `gps-globe-sat-icon ${sat.used ? 'used' : 'unused'}`;
|
||||
marker.style.setProperty('--sat-color', sat.color || '#9fb2c5');
|
||||
marker.style.setProperty('--sat-size', `${Math.max(12, Number(sat.sizePx) || 18)}px`);
|
||||
marker.title = sat.title || 'Satellite';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = sat.iconUrl || GPS_SATELLITE_ICON_URL;
|
||||
img.alt = 'Satellite';
|
||||
img.decoding = 'async';
|
||||
img.draggable = false;
|
||||
|
||||
marker.appendChild(img);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function setSatellites(satellites) {
|
||||
lastSatellites = Array.isArray(satellites) ? satellites : [];
|
||||
renderGlobe();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
renderGlobe();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true;
|
||||
if (resizeObserver) {
|
||||
try {
|
||||
resizeObserver.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
setSatellites([]);
|
||||
|
||||
return {
|
||||
setSatellites: setSatellites,
|
||||
requestRender: requestRender,
|
||||
destroy: destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSatelliteTitle(sat) {
|
||||
const constellation = String(sat.constellation || 'GPS');
|
||||
const prn = String(sat.prn || '--');
|
||||
const elevation = Number.isFinite(Number(sat.elevation)) ? `${Number(sat.elevation).toFixed(1)}\u00b0` : '--';
|
||||
const azimuth = Number.isFinite(Number(sat.azimuth)) ? `${Number(sat.azimuth).toFixed(1)}\u00b0` : '--';
|
||||
const snr = Number.isFinite(Number(sat.snr)) ? `${Math.round(Number(sat.snr))} dB-Hz` : 'n/a';
|
||||
const used = sat.used ? 'USED IN FIX' : 'TRACKED';
|
||||
|
||||
return `${constellation} PRN ${prn} | El ${elevation} | Az ${azimuth} | SNR ${snr} | ${used}`;
|
||||
}
|
||||
|
||||
function getSatelliteShellAltitude(constellation, elevation) {
|
||||
const base = CONST_ALTITUDES[constellation] || CONST_ALTITUDES.GPS;
|
||||
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
|
||||
const horizonFactor = 1 - (el / 90);
|
||||
return base + (horizonFactor * 0.04);
|
||||
}
|
||||
|
||||
function projectSkyTrackToEarth(observerLat, observerLon, azimuth, elevation) {
|
||||
const el = Math.max(0, Math.min(90, Number(elevation) || 0));
|
||||
const horizonFactor = 1 - (el / 90);
|
||||
const angularDistance = 76 * Math.pow(horizonFactor, 1.08);
|
||||
return destinationPoint(observerLat, observerLon, azimuth, angularDistance);
|
||||
}
|
||||
|
||||
function destinationPoint(latDeg, lonDeg, bearingDeg, distanceDeg) {
|
||||
const lat1 = degToRad(latDeg);
|
||||
const lon1 = degToRad(lonDeg);
|
||||
const bearing = degToRad(bearingDeg);
|
||||
const distance = degToRad(distanceDeg);
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinDist = Math.sin(distance);
|
||||
const cosDist = Math.cos(distance);
|
||||
|
||||
const sinLat2 = (sinLat1 * cosDist) + (cosLat1 * sinDist * Math.cos(bearing));
|
||||
const lat2 = Math.asin(Math.max(-1, Math.min(1, sinLat2)));
|
||||
|
||||
const y = Math.sin(bearing) * sinDist * cosLat1;
|
||||
const x = cosDist - (sinLat1 * Math.sin(lat2));
|
||||
const lon2 = lon1 + Math.atan2(y, x);
|
||||
|
||||
return {
|
||||
lat: radToDeg(lat2),
|
||||
lon: normalizeLon(radToDeg(lon2)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLon(lon) {
|
||||
let normalized = (lon + 540) % 360;
|
||||
normalized = normalized < 0 ? normalized + 360 : normalized;
|
||||
return normalized - 180;
|
||||
}
|
||||
|
||||
function radToDeg(rad) {
|
||||
return rad * 180 / Math.PI;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -78,16 +472,18 @@ const GPS = (function() {
|
||||
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');
|
||||
}
|
||||
subscribeToStream();
|
||||
startSkyPolling();
|
||||
startStatusPolling();
|
||||
// Ensure the global GPS stream is running
|
||||
const hasGlobalGpsStream = typeof gpsEventSource !== 'undefined' && !!gpsEventSource;
|
||||
if (typeof startGpsStream === 'function' && !hasGlobalGpsStream) {
|
||||
startGpsStream();
|
||||
}
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -96,36 +492,40 @@ const GPS = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
});
|
||||
function disconnect() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
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);
|
||||
}
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
if (lastSky && skyRenderer) {
|
||||
drawSkyView(lastSky.satellites || []);
|
||||
}
|
||||
} 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);
|
||||
@@ -133,18 +533,62 @@ const GPS = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
function pollSatellites() {
|
||||
if (!connected) return;
|
||||
fetch('/gps/satellites')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
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(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling();
|
||||
// Poll full status as a fallback when SSE is unavailable or blocked.
|
||||
pollStatus();
|
||||
statusPollTimer = setInterval(pollStatus, 2000);
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (!connected) return;
|
||||
fetch('/gps/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!connected || !data) return;
|
||||
if (data.running !== true) {
|
||||
connected = false;
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
updateConnectionUI(false, false, 'error', data.message || 'GPS disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.position) {
|
||||
lastPosition = data.position;
|
||||
updatePositionUI(data.position);
|
||||
updateConnectionUI(true, true);
|
||||
} else {
|
||||
updateConnectionUI(true, false);
|
||||
}
|
||||
|
||||
if (data.sky) {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function subscribeToStream() {
|
||||
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||
@@ -294,8 +738,11 @@ const GPS = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkyCanvasFallbackEnabled()) return;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
resize2DFallbackCanvas(canvas);
|
||||
drawSkyViewBase2D(canvas);
|
||||
}
|
||||
|
||||
@@ -311,9 +758,12 @@ const GPS = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkyCanvasFallbackEnabled()) return;
|
||||
|
||||
const canvas = document.getElementById('gpsSkyCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
resize2DFallbackCanvas(canvas);
|
||||
drawSkyViewBase2D(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -428,6 +878,15 @@ const GPS = (function() {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function resize2DFallbackCanvas(canvas) {
|
||||
const cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||
const cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||
if (canvas.width !== cssWidth || canvas.height !== cssHeight) {
|
||||
canvas.width = cssWidth;
|
||||
canvas.height = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createWebGlSkyRenderer(canvas, overlay) {
|
||||
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||
if (!gl) return null;
|
||||
@@ -1076,6 +1535,7 @@ const GPS = (function() {
|
||||
function destroy() {
|
||||
unsubscribeFromStream();
|
||||
stopSkyPolling();
|
||||
stopStatusPolling();
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
@@ -1085,6 +1545,8 @@ const GPS = (function() {
|
||||
skyRenderer = null;
|
||||
}
|
||||
skyRendererInitAttempted = false;
|
||||
skyRendererInitPromise = null;
|
||||
setSkyCanvasFallbackMode(false);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Morse Code (CW) decoder module.
|
||||
*
|
||||
* IIFE providing start/stop controls, SSE streaming, scope canvas,
|
||||
* decoded text display, and export capabilities.
|
||||
*/
|
||||
var MorseMode = (function () {
|
||||
'use strict';
|
||||
|
||||
var state = {
|
||||
running: false,
|
||||
initialized: false,
|
||||
eventSource: null,
|
||||
charCount: 0,
|
||||
decodedLog: [], // { timestamp, morse, char }
|
||||
};
|
||||
|
||||
// Scope state
|
||||
var scopeCtx = null;
|
||||
var scopeAnim = null;
|
||||
var scopeHistory = [];
|
||||
var SCOPE_HISTORY_LEN = 300;
|
||||
var scopeThreshold = 0;
|
||||
var scopeToneOn = false;
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
checkStatus();
|
||||
return;
|
||||
}
|
||||
state.initialized = true;
|
||||
checkStatus();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
}
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/morse/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.running) {
|
||||
state.running = true;
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
startScope();
|
||||
} else {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- Start / Stop ----
|
||||
|
||||
function start() {
|
||||
if (state.running) return;
|
||||
|
||||
var payload = {
|
||||
frequency: document.getElementById('morseFrequency').value || '14.060',
|
||||
gain: document.getElementById('morseGain').value || '0',
|
||||
ppm: document.getElementById('morsePPM').value || '0',
|
||||
device: document.getElementById('deviceSelect')?.value || '0',
|
||||
sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr',
|
||||
tone_freq: document.getElementById('morseToneFreq').value || '700',
|
||||
wpm: document.getElementById('morseWpm').value || '15',
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
};
|
||||
|
||||
fetch('/morse/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'started') {
|
||||
state.running = true;
|
||||
state.charCount = 0;
|
||||
state.decodedLog = [];
|
||||
updateUI(true);
|
||||
connectSSE();
|
||||
startScope();
|
||||
clearDecodedText();
|
||||
} else {
|
||||
alert('Error: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
alert('Failed to start Morse decoder: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/morse/stop', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ---- SSE ----
|
||||
|
||||
function connectSSE() {
|
||||
disconnectSSE();
|
||||
var es = new EventSource('/morse/stream');
|
||||
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
es.onerror = function () {
|
||||
// Reconnect handled by browser
|
||||
};
|
||||
|
||||
state.eventSource = es;
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
var type = msg.type;
|
||||
|
||||
if (type === 'scope') {
|
||||
// Update scope data
|
||||
var amps = msg.amplitudes || [];
|
||||
for (var i = 0; i < amps.length; i++) {
|
||||
scopeHistory.push(amps[i]);
|
||||
if (scopeHistory.length > SCOPE_HISTORY_LEN) {
|
||||
scopeHistory.shift();
|
||||
}
|
||||
}
|
||||
scopeThreshold = msg.threshold || 0;
|
||||
scopeToneOn = msg.tone_on || false;
|
||||
|
||||
} else if (type === 'morse_char') {
|
||||
appendChar(msg.char, msg.morse, msg.timestamp);
|
||||
|
||||
} else if (type === 'morse_space') {
|
||||
appendSpace();
|
||||
|
||||
} else if (type === 'status') {
|
||||
if (msg.status === 'stopped') {
|
||||
state.running = false;
|
||||
updateUI(false);
|
||||
disconnectSSE();
|
||||
stopScope();
|
||||
}
|
||||
} else if (type === 'error') {
|
||||
console.error('Morse error:', msg.text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Decoded text ----
|
||||
|
||||
function appendChar(ch, morse, timestamp) {
|
||||
state.charCount++;
|
||||
state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch });
|
||||
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (!panel) return;
|
||||
|
||||
var span = document.createElement('span');
|
||||
span.className = 'morse-char';
|
||||
span.textContent = ch;
|
||||
span.title = morse + ' (' + timestamp + ')';
|
||||
panel.appendChild(span);
|
||||
|
||||
// Auto-scroll
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
|
||||
// Update count
|
||||
var countEl = document.getElementById('morseCharCount');
|
||||
if (countEl) countEl.textContent = state.charCount + ' chars';
|
||||
var barChars = document.getElementById('morseStatusBarChars');
|
||||
if (barChars) barChars.textContent = state.charCount + ' chars decoded';
|
||||
}
|
||||
|
||||
function appendSpace() {
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (!panel) return;
|
||||
|
||||
var span = document.createElement('span');
|
||||
span.className = 'morse-word-space';
|
||||
span.textContent = ' ';
|
||||
panel.appendChild(span);
|
||||
}
|
||||
|
||||
function clearDecodedText() {
|
||||
var panel = document.getElementById('morseDecodedText');
|
||||
if (panel) panel.innerHTML = '';
|
||||
state.charCount = 0;
|
||||
state.decodedLog = [];
|
||||
var countEl = document.getElementById('morseCharCount');
|
||||
if (countEl) countEl.textContent = '0 chars';
|
||||
var barChars = document.getElementById('morseStatusBarChars');
|
||||
if (barChars) barChars.textContent = '0 chars decoded';
|
||||
}
|
||||
|
||||
// ---- Scope canvas ----
|
||||
|
||||
function startScope() {
|
||||
var canvas = document.getElementById('morseScopeCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 80 * dpr;
|
||||
canvas.style.height = '80px';
|
||||
|
||||
scopeCtx = canvas.getContext('2d');
|
||||
scopeCtx.scale(dpr, dpr);
|
||||
scopeHistory = [];
|
||||
|
||||
var toneLabel = document.getElementById('morseScopeToneLabel');
|
||||
var threshLabel = document.getElementById('morseScopeThreshLabel');
|
||||
|
||||
function draw() {
|
||||
if (!scopeCtx) return;
|
||||
var w = rect.width;
|
||||
var h = 80;
|
||||
|
||||
scopeCtx.fillStyle = '#050510';
|
||||
scopeCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// Update header labels
|
||||
if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--';
|
||||
if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--';
|
||||
|
||||
if (scopeHistory.length === 0) {
|
||||
scopeAnim = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find max for normalization
|
||||
var maxVal = 0;
|
||||
for (var i = 0; i < scopeHistory.length; i++) {
|
||||
if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
|
||||
}
|
||||
if (maxVal === 0) maxVal = 1;
|
||||
|
||||
var barW = w / SCOPE_HISTORY_LEN;
|
||||
var threshNorm = scopeThreshold / maxVal;
|
||||
|
||||
// Draw amplitude bars
|
||||
for (var j = 0; j < scopeHistory.length; j++) {
|
||||
var norm = scopeHistory[j] / maxVal;
|
||||
var barH = norm * (h - 10);
|
||||
var x = j * barW;
|
||||
var y = h - barH;
|
||||
|
||||
// Green if above threshold, gray if below
|
||||
if (scopeHistory[j] > scopeThreshold) {
|
||||
scopeCtx.fillStyle = '#00ff88';
|
||||
} else {
|
||||
scopeCtx.fillStyle = '#334455';
|
||||
}
|
||||
scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH);
|
||||
}
|
||||
|
||||
// Draw threshold line
|
||||
if (scopeThreshold > 0) {
|
||||
var threshY = h - (threshNorm * (h - 10));
|
||||
scopeCtx.strokeStyle = '#ff4444';
|
||||
scopeCtx.lineWidth = 1;
|
||||
scopeCtx.setLineDash([4, 4]);
|
||||
scopeCtx.beginPath();
|
||||
scopeCtx.moveTo(0, threshY);
|
||||
scopeCtx.lineTo(w, threshY);
|
||||
scopeCtx.stroke();
|
||||
scopeCtx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Tone indicator
|
||||
if (scopeToneOn) {
|
||||
scopeCtx.fillStyle = '#00ff88';
|
||||
scopeCtx.beginPath();
|
||||
scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2);
|
||||
scopeCtx.fill();
|
||||
}
|
||||
|
||||
scopeAnim = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
function stopScope() {
|
||||
if (scopeAnim) {
|
||||
cancelAnimationFrame(scopeAnim);
|
||||
scopeAnim = null;
|
||||
}
|
||||
scopeCtx = null;
|
||||
}
|
||||
|
||||
// ---- Export ----
|
||||
|
||||
function exportTxt() {
|
||||
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
|
||||
downloadFile('morse_decoded.txt', text, 'text/plain');
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
var lines = ['timestamp,morse,character'];
|
||||
state.decodedLog.forEach(function (e) {
|
||||
lines.push(e.timestamp + ',"' + e.morse + '",' + e.char);
|
||||
});
|
||||
downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
var text = state.decodedLog.map(function (e) { return e.char; }).join('');
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
var btn = document.getElementById('morseCopyBtn');
|
||||
if (btn) {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(filename, content, type) {
|
||||
var blob = new Blob([content], { type: type });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
|
||||
function updateUI(running) {
|
||||
var startBtn = document.getElementById('morseStartBtn');
|
||||
var stopBtn = document.getElementById('morseStopBtn');
|
||||
var indicator = document.getElementById('morseStatusIndicator');
|
||||
var statusText = document.getElementById('morseStatusText');
|
||||
|
||||
if (startBtn) startBtn.style.display = running ? 'none' : '';
|
||||
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
|
||||
|
||||
if (indicator) {
|
||||
indicator.style.background = running ? '#00ff88' : 'var(--text-dim)';
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.textContent = running ? 'Listening' : 'Standby';
|
||||
}
|
||||
|
||||
// Toggle scope and output panels (pager/sensor pattern)
|
||||
var scopePanel = document.getElementById('morseScopePanel');
|
||||
var outputPanel = document.getElementById('morseOutputPanel');
|
||||
if (scopePanel) scopePanel.style.display = running ? 'block' : 'none';
|
||||
if (outputPanel) outputPanel.style.display = running ? 'block' : 'none';
|
||||
|
||||
var scopeStatus = document.getElementById('morseScopeStatusLabel');
|
||||
if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE';
|
||||
if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444';
|
||||
}
|
||||
|
||||
function setFreq(mhz) {
|
||||
var el = document.getElementById('morseFrequency');
|
||||
if (el) el.value = mhz;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
start: start,
|
||||
stop: stop,
|
||||
setFreq: setFreq,
|
||||
exportTxt: exportTxt,
|
||||
exportCsv: exportCsv,
|
||||
copyToClipboard: copyToClipboard,
|
||||
clearText: clearDecodedText,
|
||||
};
|
||||
})();
|
||||
+105
-65
@@ -36,6 +36,7 @@ const Waterfall = (function () {
|
||||
|
||||
let _startMhz = 98.8;
|
||||
let _endMhz = 101.2;
|
||||
let _lastEffectiveSpan = 2.4;
|
||||
let _monitorFreqMhz = 100.0;
|
||||
|
||||
let _monitoring = false;
|
||||
@@ -2515,6 +2516,11 @@ const Waterfall = (function () {
|
||||
_endMhz = msg.end_freq;
|
||||
_drawFreqAxis();
|
||||
}
|
||||
if (Number.isFinite(msg.effective_span_mhz)) {
|
||||
_lastEffectiveSpan = msg.effective_span_mhz;
|
||||
const spanEl = document.getElementById('wfSpanMhz');
|
||||
if (spanEl) spanEl.value = msg.effective_span_mhz;
|
||||
}
|
||||
_setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`);
|
||||
_setVisualStatus('RUNNING');
|
||||
if (_monitoring) {
|
||||
@@ -2535,6 +2541,12 @@ const Waterfall = (function () {
|
||||
}
|
||||
_updateFreqDisplay();
|
||||
_setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`);
|
||||
if (_monitoring && _monitorSource === 'waterfall') {
|
||||
const mode = _getMonitorMode().toUpperCase();
|
||||
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`);
|
||||
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
}
|
||||
if (!_monitoring) _setVisualStatus('RUNNING');
|
||||
} else if (_onRetuneRequired(msg)) {
|
||||
return;
|
||||
@@ -2557,6 +2569,10 @@ const Waterfall = (function () {
|
||||
_pendingMonitorTuneMhz = null;
|
||||
_scanStartPending = false;
|
||||
_pendingSharedMonitorRearm = false;
|
||||
// Reset span input to last known good value so an
|
||||
// invalid span doesn't persist across restart (#150).
|
||||
const spanEl = document.getElementById('wfSpanMhz');
|
||||
if (spanEl) spanEl.value = _lastEffectiveSpan;
|
||||
// If the monitor was using the shared IQ stream that
|
||||
// just failed, tear down the stale monitor state so
|
||||
// the button becomes clickable again after restart.
|
||||
@@ -2603,7 +2619,7 @@ const Waterfall = (function () {
|
||||
player.load();
|
||||
}
|
||||
|
||||
async function _attachMonitorAudio(nonce) {
|
||||
async function _attachMonitorAudio(nonce, streamToken = null) {
|
||||
const player = document.getElementById('wfAudioPlayer');
|
||||
if (!player) {
|
||||
return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' };
|
||||
@@ -2622,7 +2638,10 @@ const Waterfall = (function () {
|
||||
}
|
||||
|
||||
await _pauseMonitorAudioElement();
|
||||
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`;
|
||||
const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0)
|
||||
? `&request_token=${encodeURIComponent(String(streamToken))}`
|
||||
: '';
|
||||
player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`;
|
||||
player.load();
|
||||
|
||||
try {
|
||||
@@ -2678,25 +2697,6 @@ const Waterfall = (function () {
|
||||
};
|
||||
}
|
||||
|
||||
function _deviceKey(device) {
|
||||
if (!device) return '';
|
||||
return `${device.sdrType || ''}:${device.deviceIndex || 0}`;
|
||||
}
|
||||
|
||||
function _findAlternateDevice(currentDevice) {
|
||||
const currentKey = _deviceKey(currentDevice);
|
||||
for (const d of _devices) {
|
||||
const candidate = {
|
||||
sdrType: String(d.sdr_type || 'rtlsdr'),
|
||||
deviceIndex: parseInt(d.index, 10) || 0,
|
||||
};
|
||||
if (_deviceKey(candidate) !== currentKey) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _requestAudioStart({
|
||||
frequency,
|
||||
modulation,
|
||||
@@ -2760,6 +2760,7 @@ const Waterfall = (function () {
|
||||
_resumeWaterfallAfterMonitor = !!wasRunningWaterfall;
|
||||
}
|
||||
|
||||
const liveCenterMhz = _currentCenter();
|
||||
// Keep an explicit pending tune target so retunes cannot fall
|
||||
// back to a stale frequency during capture restart churn.
|
||||
const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz)
|
||||
@@ -2767,11 +2768,11 @@ const Waterfall = (function () {
|
||||
: (
|
||||
Number.isFinite(_pendingCaptureVfoMhz)
|
||||
? _pendingCaptureVfoMhz
|
||||
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter())
|
||||
: (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz)
|
||||
);
|
||||
const centerMhz = retuneOnly
|
||||
? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter())
|
||||
: _currentCenter();
|
||||
? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz)
|
||||
: liveCenterMhz;
|
||||
const mode = document.getElementById('wfMonitorMode')?.value || 'wfm';
|
||||
const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0;
|
||||
const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10);
|
||||
@@ -2780,69 +2781,98 @@ const Waterfall = (function () {
|
||||
? sliderGain
|
||||
: (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40);
|
||||
const selectedDevice = _selectedDevice();
|
||||
const altDevice = _running ? _findAlternateDevice(selectedDevice) : null;
|
||||
let monitorDevice = altDevice || selectedDevice;
|
||||
// Always target the currently selected SDR for monitor start/retune.
|
||||
// This keeps waterfall-shared monitor tuning deterministic and avoids
|
||||
// retuning a different receiver than the one driving the display.
|
||||
let monitorDevice = selectedDevice;
|
||||
const biasT = !!document.getElementById('wfBiasT')?.checked;
|
||||
const usingSecondaryDevice = !!altDevice;
|
||||
// Use a high monotonic token so backend start ordering remains
|
||||
// valid across page reloads (local nonces reset to small values).
|
||||
const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff));
|
||||
|
||||
if (!retuneOnly) {
|
||||
_monitorFreqMhz = centerMhz;
|
||||
} else if (Number.isFinite(centerMhz)) {
|
||||
_monitorFreqMhz = centerMhz;
|
||||
_pendingMonitorTuneMhz = centerMhz;
|
||||
_pendingCaptureVfoMhz = centerMhz;
|
||||
}
|
||||
_drawFreqAxis();
|
||||
_stopSmeter();
|
||||
_setUnlockVisible(false);
|
||||
_audioUnlockRequired = false;
|
||||
|
||||
if (usingSecondaryDevice) {
|
||||
if (retuneOnly && _monitoring) {
|
||||
_setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
|
||||
} else {
|
||||
_setMonitorState(
|
||||
`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on `
|
||||
+ `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...`
|
||||
);
|
||||
} else {
|
||||
_setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`);
|
||||
}
|
||||
|
||||
// Use live _monitorFreqMhz for retunes so that any user
|
||||
// clicks that changed the VFO during the async setup are
|
||||
// picked up rather than overridden.
|
||||
let { response, payload } = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: monitorDevice,
|
||||
biasT,
|
||||
requestToken: nonce,
|
||||
});
|
||||
const requestAudioStartResynced = async (deviceForRequest) => {
|
||||
let startResult = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: deviceForRequest,
|
||||
biasT,
|
||||
requestToken,
|
||||
});
|
||||
const startPayload = startResult?.payload || {};
|
||||
const isStale = startPayload.superseded === true || startPayload.status === 'stale';
|
||||
if (isStale) {
|
||||
const currentToken = Number(startPayload.current_token);
|
||||
if (Number.isFinite(currentToken) && currentToken >= 0) {
|
||||
startResult = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: deviceForRequest,
|
||||
biasT,
|
||||
requestToken: currentToken + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return startResult;
|
||||
};
|
||||
|
||||
let { response, payload } = await requestAudioStartResynced(monitorDevice);
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
|
||||
const staleStart = payload?.superseded === true || payload?.status === 'stale';
|
||||
if (staleStart) return;
|
||||
if (staleStart) {
|
||||
// If the backend still reports stale after token resync,
|
||||
// schedule a fresh retune so monitor audio does not stay on
|
||||
// an older station indefinitely.
|
||||
if (_monitoring) {
|
||||
const liveMode = _getMonitorMode().toUpperCase();
|
||||
_setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`);
|
||||
_setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
_queueMonitorRetune(90);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
|
||||
if (
|
||||
busy
|
||||
&& _running
|
||||
&& !usingSecondaryDevice
|
||||
&& !retuneOnly
|
||||
) {
|
||||
if (busy && _running && !retuneOnly) {
|
||||
_setMonitorState('Audio device busy, pausing waterfall and retrying monitor...');
|
||||
await stop({ keepStatus: true });
|
||||
_resumeWaterfallAfterMonitor = true;
|
||||
await _wait(220);
|
||||
monitorDevice = selectedDevice;
|
||||
({ response, payload } = await _requestAudioStart({
|
||||
frequency: centerMhz,
|
||||
modulation: mode,
|
||||
squelch,
|
||||
gain,
|
||||
device: monitorDevice,
|
||||
biasT,
|
||||
requestToken: nonce,
|
||||
}));
|
||||
({ response, payload } = await requestAudioStartResynced(monitorDevice));
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
if (payload?.superseded === true || payload?.status === 'stale') return;
|
||||
if (payload?.superseded === true || payload?.status === 'stale') {
|
||||
if (_monitoring) _queueMonitorRetune(90);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || payload.status !== 'started') {
|
||||
@@ -2861,13 +2891,14 @@ const Waterfall = (function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const attach = await _attachMonitorAudio(nonce);
|
||||
const attach = await _attachMonitorAudio(nonce, payload?.request_token);
|
||||
if (nonce !== _audioConnectNonce) return;
|
||||
_monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process';
|
||||
if (
|
||||
const pendingTuneMismatch = (
|
||||
Number.isFinite(_pendingMonitorTuneMhz)
|
||||
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6
|
||||
) {
|
||||
&& Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6
|
||||
);
|
||||
if (!pendingTuneMismatch) {
|
||||
_pendingMonitorTuneMhz = null;
|
||||
}
|
||||
|
||||
@@ -2878,6 +2909,7 @@ const Waterfall = (function () {
|
||||
_setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`);
|
||||
_setStatus('Monitor started but browser blocked playback. Click Unlock Audio.');
|
||||
_setVisualStatus('MONITOR');
|
||||
if (pendingTuneMismatch) _queueMonitorRetune(45);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2911,20 +2943,27 @@ const Waterfall = (function () {
|
||||
_setMonitorState(
|
||||
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ`
|
||||
);
|
||||
} else if (usingSecondaryDevice) {
|
||||
} else {
|
||||
_setMonitorState(
|
||||
`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} `
|
||||
+ `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}`
|
||||
);
|
||||
} else {
|
||||
_setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`);
|
||||
}
|
||||
_setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`);
|
||||
_setVisualStatus('MONITOR');
|
||||
if (pendingTuneMismatch) {
|
||||
_queueMonitorRetune(45);
|
||||
}
|
||||
// After a retune reconnect, sync the backend to the latest
|
||||
// VFO in case the user clicked a new frequency while the
|
||||
// audio stream was reconnecting.
|
||||
if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||
if (
|
||||
!pendingTuneMismatch
|
||||
&& retuneOnly
|
||||
&& _monitorSource === 'waterfall'
|
||||
&& _ws
|
||||
&& _ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
_sendWsTuneCmd();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3233,7 +3272,8 @@ const Waterfall = (function () {
|
||||
|
||||
function stepFreq(multiplier) {
|
||||
const step = _getNumber('wfStepSize', 0.1);
|
||||
_setAndTune(_currentCenter() + multiplier * step, true);
|
||||
// Coalesce rapid step-button presses into one final retune.
|
||||
_setAndTune(_currentCenter() + multiplier * step, false);
|
||||
}
|
||||
|
||||
function zoomBy(factor) {
|
||||
|
||||
@@ -265,10 +265,13 @@ const WeatherSat = (function() {
|
||||
* Stop capture
|
||||
*/
|
||||
async function stop() {
|
||||
// Optimistically update UI immediately so stop feels responsive,
|
||||
// even if the server takes time to terminate the process.
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopping...');
|
||||
try {
|
||||
await fetch('/weather-sat/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('Weather Sat', 'Capture stopped');
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,7 +19,6 @@ let websdrResizeHooked = false;
|
||||
let websdrGlobeFallbackNotified = false;
|
||||
|
||||
const WEBSDR_GLOBE_SCRIPT_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||
];
|
||||
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||
@@ -186,8 +185,34 @@ async function ensureWebsdrGlobeLibrary() {
|
||||
}
|
||||
|
||||
function loadWebsdrScript(src) {
|
||||
const state = getSharedGlobeScriptState();
|
||||
if (!state.promises[src]) {
|
||||
state.promises[src] = loadSharedGlobeScript(src);
|
||||
}
|
||||
return state.promises[src].catch((error) => {
|
||||
delete state.promises[src];
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function getSharedGlobeScriptState() {
|
||||
const key = '__interceptGlobeScriptState';
|
||||
if (!window[key]) {
|
||||
window[key] = {
|
||||
promises: Object.create(null),
|
||||
};
|
||||
}
|
||||
return window[key];
|
||||
}
|
||||
|
||||
function loadSharedGlobeScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const selector = `script[data-websdr-src="${src}"]`;
|
||||
const selector = [
|
||||
`script[data-intercept-globe-src="${src}"]`,
|
||||
`script[data-websdr-src="${src}"]`,
|
||||
`script[data-gps-globe-src="${src}"]`,
|
||||
`script[src="${src}"]`,
|
||||
].join(', ');
|
||||
const existing = document.querySelector(selector);
|
||||
|
||||
if (existing) {
|
||||
@@ -208,6 +233,7 @@ function loadWebsdrScript(src) {
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.interceptGlobeSrc = src;
|
||||
script.dataset.websdrSrc = src;
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user