mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 15:20:00 -07:00
feat: ship waterfall receiver overhaul and platform mode updates
This commit is contained in:
@@ -1802,6 +1802,14 @@ header h1 .tagline {
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
@keyframes stop-btn-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
|
||||
}
|
||||
.stop-btn {
|
||||
animation: stop-btn-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
/* Analytics Dashboard Styles */
|
||||
|
||||
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
||||
@media (min-width: 1024px) {
|
||||
.main-content.analytics-active {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.main-content.analytics-active > .output-panel {
|
||||
display: none !important;
|
||||
}
|
||||
.main-content.analytics-active > .sidebar {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.main-content.analytics-active .sidebar-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.main-content.analytics-active > .output-panel {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-insight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.analytics-insight-card {
|
||||
background: var(--bg-card, #151f2b);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analytics-insight-card.low {
|
||||
border-color: rgba(90, 106, 122, 0.5);
|
||||
}
|
||||
|
||||
.analytics-insight-card.medium {
|
||||
border-color: rgba(74, 163, 255, 0.45);
|
||||
}
|
||||
|
||||
.analytics-insight-card.high {
|
||||
border-color: rgba(214, 168, 94, 0.55);
|
||||
}
|
||||
|
||||
.analytics-insight-card.critical {
|
||||
border-color: rgba(226, 93, 93, 0.65);
|
||||
}
|
||||
|
||||
.analytics-insight-card .insight-title {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.analytics-insight-card .insight-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.analytics-insight-card .insight-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #9aabba);
|
||||
}
|
||||
|
||||
.analytics-insight-card .insight-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.analytics-top-changes {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.analytics-change-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.analytics-change-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.analytics-change-row .mode {
|
||||
min-width: 84px;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.analytics-change-row .delta {
|
||||
min-width: 48px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.analytics-change-row .delta.up {
|
||||
color: var(--accent-green, #38c180);
|
||||
}
|
||||
|
||||
.analytics-change-row .delta.down {
|
||||
color: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.analytics-change-row .delta.flat {
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.analytics-change-row .avg {
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
background: var(--bg-card, #151f2b);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
text-align: center;
|
||||
transition: var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.analytics-card:hover {
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.analytics-card .card-count {
|
||||
font-size: var(--text-2xl, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.analytics-card .card-label {
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1, 4px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline {
|
||||
height: 24px;
|
||||
margin-top: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: var(--accent-cyan, #4aa3ff);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Health indicators */
|
||||
.analytics-health {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 8px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 4px);
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.health-dot.running {
|
||||
background: var(--accent-green, #38c180);
|
||||
}
|
||||
|
||||
/* Emergency squawk panel */
|
||||
.squawk-emergency {
|
||||
background: rgba(226, 93, 93, 0.1);
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-title {
|
||||
color: var(--accent-red, #e25d5d);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm, 12px);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item {
|
||||
font-size: var(--text-sm, 12px);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
padding: var(--space-1, 4px) 0;
|
||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Alert feed */
|
||||
.analytics-alert-feed {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-alert-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-alert-item .alert-severity {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
||||
|
||||
/* Correlation panel */
|
||||
.analytics-correlation-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-bar {
|
||||
height: 4px;
|
||||
background: var(--bg-secondary, #101823);
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-green, #38c180);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.analytics-pattern-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analytics-pattern-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.analytics-pattern-item .pattern-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.analytics-pattern-item .pattern-mode {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.analytics-pattern-item .pattern-device {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.analytics-pattern-item .pattern-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analytics-pattern-item .pattern-confidence {
|
||||
color: var(--accent-green, #38c180);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Geofence zone list */
|
||||
.geofence-zone-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-radius {
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-delete {
|
||||
cursor: pointer;
|
||||
color: var(--accent-red, #e25d5d);
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Export controls */
|
||||
.export-controls {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-controls select,
|
||||
.export-controls button {
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
background: var(--bg-card, #151f2b);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.export-controls button {
|
||||
cursor: pointer;
|
||||
background: var(--accent-cyan, #4aa3ff);
|
||||
color: #fff;
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.export-controls button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.analytics-section-header {
|
||||
font-size: var(--text-xs, 10px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
padding-bottom: var(--space-1, 4px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.analytics-empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-4, 16px);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.analytics-target-toolbar,
|
||||
.analytics-replay-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.analytics-target-toolbar input {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
background: var(--bg-card, #151f2b);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.analytics-target-toolbar button,
|
||||
.analytics-replay-toolbar button,
|
||||
.analytics-replay-toolbar select {
|
||||
font-size: 10px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
background: var(--bg-card, #151f2b);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
}
|
||||
|
||||
.analytics-target-toolbar button,
|
||||
.analytics-replay-toolbar button {
|
||||
cursor: pointer;
|
||||
background: rgba(74, 163, 255, 0.2);
|
||||
border-color: rgba(74, 163, 255, 0.45);
|
||||
}
|
||||
|
||||
.analytics-target-summary {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.analytics-target-item,
|
||||
.analytics-replay-item {
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
padding: 7px 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analytics-target-item:last-child,
|
||||
.analytics-replay-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.analytics-target-item .title,
|
||||
.analytics-replay-item .title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.analytics-target-item .mode,
|
||||
.analytics-replay-item .mode {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid rgba(74, 163, 255, 0.35);
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.analytics-target-item .meta,
|
||||
.analytics-replay-item .meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -163,29 +163,29 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btl-hud-export-format {
|
||||
min-width: 62px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btl-hud-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btl-hud-export-format {
|
||||
min-width: 62px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle {
|
||||
display: flex;
|
||||
@@ -266,112 +266,114 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#btLocateMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-heat-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btl-map-heat-bar {
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btl-map-heat-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-heat-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btl-map-heat-bar {
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btl-map-heat-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container {
|
||||
height: 100px;
|
||||
@@ -511,7 +513,7 @@
|
||||
RESPONSIVE — stack HUD vertically on narrow
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 900px) {
|
||||
.btl-hud {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
@@ -528,33 +530,33 @@
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.btl-hud-controls {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
gap: 3px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
.btl-hud-controls {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
gap: 3px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
78
static/css/modes/fingerprint.css
Normal file
78
static/css/modes/fingerprint.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* Signal Fingerprinting Mode Styles */
|
||||
|
||||
.fp-tab-btn {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #aaa);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.fp-tab-btn.active {
|
||||
background: rgba(74,163,255,0.15);
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.fp-anomaly-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fp-anomaly-item.severity-alert {
|
||||
background: rgba(239,68,68,0.12);
|
||||
border-color: rgba(239,68,68,0.4);
|
||||
}
|
||||
|
||||
.fp-anomaly-item.severity-warn {
|
||||
background: rgba(251,191,36,0.1);
|
||||
border-color: rgba(251,191,36,0.4);
|
||||
}
|
||||
|
||||
.fp-anomaly-item.severity-new {
|
||||
background: rgba(168,85,247,0.12);
|
||||
border-color: rgba(168,85,247,0.4);
|
||||
}
|
||||
|
||||
.fp-anomaly-band {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fp-anomaly-type-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.fp-chart-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fpChartCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
44
static/css/modes/rfheatmap.css
Normal file
44
static/css/modes/rfheatmap.css
Normal file
@@ -0,0 +1,44 @@
|
||||
/* RF Heatmap Mode Styles */
|
||||
|
||||
.rfhm-map-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#rfheatmapMapEl {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rfhm-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rfhm-stat-chip {
|
||||
background: rgba(0,0,0,0.75);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.rfhm-recording-pulse {
|
||||
animation: rfhm-rec 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rfhm-rec {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
666
static/css/modes/waterfall.css
Normal file
666
static/css/modes/waterfall.css
Normal file
@@ -0,0 +1,666 @@
|
||||
/* Spectrum Waterfall Mode Styles */
|
||||
|
||||
.wf-container {
|
||||
--wf-border: rgba(92, 153, 255, 0.24);
|
||||
--wf-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(circle at 14% -18%, rgba(36, 129, 255, 0.2) 0%, rgba(36, 129, 255, 0) 38%),
|
||||
radial-gradient(circle at 86% -26%, rgba(255, 161, 54, 0.2) 0%, rgba(255, 161, 54, 0) 36%),
|
||||
#03070f;
|
||||
border: 1px solid var(--wf-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wf-headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(8, 14, 25, 0.86);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-headline-left,
|
||||
.wf-headline-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-headline-tag {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid rgba(74, 163, 255, 0.45);
|
||||
background: rgba(74, 163, 255, 0.13);
|
||||
color: #8ec5ff;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-headline-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.wf-range-text,
|
||||
.wf-tune-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-tune-text {
|
||||
color: #ffd782;
|
||||
}
|
||||
|
||||
.wf-monitor-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.5fr) minmax(220px, 1fr) minmax(230px, 1.2fr) minmax(130px, 0.7fr) minmax(220px, 1fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
padding: 8px 12px;
|
||||
background: var(--wf-surface);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-rx-vfo {
|
||||
border: 1px solid rgba(102, 171, 255, 0.27);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(8, 16, 31, 0.92) 0%, rgba(4, 9, 18, 0.95) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.wf-rx-vfo-top,
|
||||
.wf-rx-vfo-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-rx-vfo-name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-rx-vfo-status {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
color: #a6cbff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.wf-rx-vfo-readout {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: #7bc4ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#wfRxFreqReadout {
|
||||
font-size: 32px;
|
||||
letter-spacing: 0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 0 16px rgba(44, 153, 255, 0.28);
|
||||
}
|
||||
|
||||
.wf-rx-vfo-unit {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.wf-rx-vfo-bottom {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.wf-rx-modebank {
|
||||
border: 1px solid rgba(92, 153, 255, 0.24);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(4, 10, 20, 0.86);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(42px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.wf-mode-btn {
|
||||
border: 1px solid rgba(118, 176, 255, 0.26);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, rgba(20, 37, 66, 0.95) 0%, rgba(13, 26, 49, 0.95) 100%);
|
||||
color: #d1e5ff;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.wf-mode-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(143, 196, 255, 0.52);
|
||||
}
|
||||
|
||||
.wf-mode-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wf-mode-btn.is-active,
|
||||
.wf-mode-btn.active {
|
||||
border-color: rgba(97, 198, 255, 0.62);
|
||||
background: linear-gradient(180deg, rgba(23, 85, 146, 0.92) 0%, rgba(18, 57, 104, 0.95) 100%);
|
||||
color: #f3fbff;
|
||||
box-shadow: 0 0 14px rgba(53, 152, 255, 0.28);
|
||||
}
|
||||
|
||||
.wf-monitor-select-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-rx-levels {
|
||||
border: 1px solid rgba(92, 153, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgba(4, 10, 20, 0.85);
|
||||
padding: 7px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-monitor-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-monitor-label {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wf-monitor-select {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(92, 153, 255, 0.28);
|
||||
background: rgba(4, 8, 16, 0.8);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.wf-monitor-slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-monitor-slider-wrap input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.wf-monitor-value {
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wf-rx-meter-wrap {
|
||||
border: 1px solid rgba(92, 153, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgba(4, 10, 20, 0.85);
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-rx-smeter {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(18, 44, 22, 0.95) 0%, rgba(46, 67, 20, 0.95) 55%, rgba(78, 28, 24, 0.95) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-rx-smeter-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, rgba(86, 243, 146, 0.75) 0%, rgba(255, 208, 94, 0.78) 64%, rgba(255, 118, 118, 0.82) 100%);
|
||||
box-shadow: 0 0 10px rgba(97, 229, 255, 0.35);
|
||||
transition: width 90ms linear;
|
||||
}
|
||||
|
||||
.wf-rx-smeter-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wf-rx-actions {
|
||||
border: 1px solid rgba(92, 153, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgba(4, 10, 20, 0.85);
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wf-rx-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wf-monitor-btn {
|
||||
height: 32px;
|
||||
min-width: 90px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(86, 195, 124, 0.5);
|
||||
background: linear-gradient(180deg, rgba(33, 125, 67, 0.95) 0%, rgba(21, 88, 47, 0.95) 100%);
|
||||
color: #d2ffe2;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: filter 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.wf-monitor-btn:hover {
|
||||
filter: brightness(1.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wf-monitor-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: saturate(0.6);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wf-monitor-btn-secondary {
|
||||
border-color: rgba(92, 153, 255, 0.5);
|
||||
background: linear-gradient(180deg, rgba(34, 66, 121, 0.95) 0%, rgba(19, 41, 84, 0.95) 100%);
|
||||
color: #d4e7ff;
|
||||
}
|
||||
|
||||
.wf-monitor-btn-unlock {
|
||||
border-color: rgba(214, 168, 94, 0.55);
|
||||
background: linear-gradient(180deg, rgba(134, 93, 31, 0.95) 0%, rgba(98, 65, 19, 0.95) 100%);
|
||||
color: #ffe8bd;
|
||||
}
|
||||
|
||||
.wf-monitor-btn.is-active {
|
||||
border-color: rgba(255, 129, 129, 0.55);
|
||||
background: linear-gradient(180deg, rgba(127, 36, 48, 0.95) 0%, rgba(84, 21, 31, 0.95) 100%);
|
||||
color: #ffd9de;
|
||||
}
|
||||
|
||||
.wf-monitor-state {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
#wfAudioPlayer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Frequency control bar */
|
||||
|
||||
.wf-freq-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(8, 13, 24, 0.78);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
flex-shrink: 0;
|
||||
min-height: 38px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wf-freq-bar-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wf-step-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
font-size: 14px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.wf-step-btn:hover {
|
||||
background: rgba(74, 163, 255, 0.17);
|
||||
border-color: rgba(74, 163, 255, 0.45);
|
||||
}
|
||||
|
||||
.wf-step-btn:active {
|
||||
background: rgba(74, 163, 255, 0.28);
|
||||
}
|
||||
|
||||
.wf-freq-display-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(74, 163, 255, 0.28);
|
||||
border-radius: 5px;
|
||||
padding: 3px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-freq-center-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
width: 110px;
|
||||
text-align: right;
|
||||
padding: 0;
|
||||
cursor: text;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.wf-freq-center-input:focus {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wf-freq-bar-unit {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #555);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.wf-step-select {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary, #aaa);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-freq-bar-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
margin: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-span-display {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Spectrum canvas */
|
||||
|
||||
.wf-spectrum-canvas-wrap {
|
||||
height: 108px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
|
||||
background: radial-gradient(circle at 50% -120%, rgba(84, 140, 237, 0.18) 0%, rgba(84, 140, 237, 0) 65%);
|
||||
}
|
||||
|
||||
#wfSpectrumCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
|
||||
.wf-resize-handle {
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.wf-resize-handle:hover,
|
||||
.wf-resize-handle.dragging {
|
||||
background: rgba(74, 163, 255, 0.14);
|
||||
}
|
||||
|
||||
.wf-resize-grip {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 1px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wf-resize-handle:hover .wf-resize-grip,
|
||||
.wf-resize-handle.dragging .wf-resize-grip {
|
||||
background: rgba(74, 163, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Waterfall canvas */
|
||||
|
||||
.wf-waterfall-canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
|
||||
background-size: 44px 100%;
|
||||
}
|
||||
|
||||
#wfWaterfallCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Center/tune lines */
|
||||
|
||||
.wf-center-line,
|
||||
.wf-tune-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.wf-center-line {
|
||||
left: calc(50% - 0.5px);
|
||||
background: rgba(255, 215, 0, 0.38);
|
||||
}
|
||||
|
||||
.wf-tune-line {
|
||||
left: calc(50% - 0.5px);
|
||||
background: rgba(130, 220, 255, 0.75);
|
||||
box-shadow: 0 0 8px rgba(74, 163, 255, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.wf-tune-line.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Frequency axis */
|
||||
|
||||
.wf-freq-axis {
|
||||
height: 21px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: rgba(8, 13, 24, 0.86);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.wf-freq-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #555);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.wf-freq-tick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Hover tooltip */
|
||||
|
||||
.wf-tooltip {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
background: rgba(0, 0, 0, 0.84);
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.wf-monitor-strip {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
grid-auto-rows: minmax(70px, auto);
|
||||
}
|
||||
|
||||
.wf-rx-actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.wf-rx-action-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.wf-headline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wf-headline-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wf-monitor-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wf-rx-actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.wf-freq-bar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.wf-freq-center-input {
|
||||
width: 96px;
|
||||
}
|
||||
}
|
||||
21
static/icons/icon.svg
Normal file
21
static/icons/icon.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#0b1118" rx="80"/>
|
||||
<!-- Signal wave arcs radiating from center-left -->
|
||||
<g fill="none" stroke="#4aa3ff" stroke-linecap="round">
|
||||
<!-- Inner arc -->
|
||||
<path stroke-width="22" d="M 160 256 Q 192 210 192 256 Q 192 302 160 256" opacity="0.5"/>
|
||||
<!-- Small arc -->
|
||||
<path stroke-width="22" d="M 130 256 Q 180 185 180 256 Q 180 327 130 256" opacity="0.65"/>
|
||||
<!-- Medium arc -->
|
||||
<path stroke-width="24" d="M 100 256 Q 175 155 175 256 Q 175 357 100 256" opacity="0.8"/>
|
||||
<!-- Large arc -->
|
||||
<path stroke-width="26" d="M 68 256 Q 170 120 170 256 Q 170 392 68 256" opacity="0.95"/>
|
||||
</g>
|
||||
<!-- Horizontal beam line -->
|
||||
<line x1="190" y1="256" x2="420" y2="256" stroke="#4aa3ff" stroke-width="20" stroke-linecap="round"/>
|
||||
<!-- Signal dot at origin -->
|
||||
<circle cx="190" cy="256" r="18" fill="#4aa3ff"/>
|
||||
<!-- Target reticle at end -->
|
||||
<circle cx="420" cy="256" r="28" fill="none" stroke="#4aa3ff" stroke-width="14"/>
|
||||
<circle cx="420" cy="256" r="8" fill="#4aa3ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
77
static/js/core/cheat-sheets.js
Normal file
77
static/js/core/cheat-sheets.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* INTERCEPT Per-Mode Cheat Sheets */
|
||||
const CheatSheets = (function () {
|
||||
'use strict';
|
||||
|
||||
const CONTENT = {
|
||||
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
|
||||
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
|
||||
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
|
||||
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
|
||||
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118–136 MHz AM', 'Marine VHF: 156–174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] },
|
||||
fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] },
|
||||
};
|
||||
|
||||
function show(mode) {
|
||||
const data = CONTENT[mode];
|
||||
const modal = document.getElementById('cheatSheetModal');
|
||||
const content = document.getElementById('cheatSheetContent');
|
||||
if (!modal || !content) return;
|
||||
|
||||
if (!data) {
|
||||
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
|
||||
} else {
|
||||
content.innerHTML = `
|
||||
<div style="font-family:var(--font-mono, monospace);">
|
||||
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
|
||||
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
|
||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
|
||||
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
|
||||
</div>
|
||||
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
|
||||
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
|
||||
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
|
||||
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const modal = document.getElementById('cheatSheetModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function showForCurrentMode() {
|
||||
const mode = document.body.getAttribute('data-mode');
|
||||
if (mode) show(mode);
|
||||
}
|
||||
|
||||
return { show, hide, showForCurrentMode };
|
||||
})();
|
||||
|
||||
window.CheatSheets = CheatSheets;
|
||||
@@ -25,7 +25,6 @@ const CommandPalette = (function() {
|
||||
{ mode: 'gps', label: 'GPS' },
|
||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||
{ mode: 'websdr', label: 'WebSDR' },
|
||||
{ mode: 'analytics', label: 'Analytics' },
|
||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||
];
|
||||
|
||||
|
||||
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
|
||||
['sstv', 'ISS SSTV'],
|
||||
['weathersat', 'Weather Sat'],
|
||||
['sstv_general', 'HF SSTV'],
|
||||
['analytics', 'Analytics'],
|
||||
];
|
||||
for (const [value, label] of modes) {
|
||||
const opt = document.createElement('option');
|
||||
|
||||
74
static/js/core/keyboard-shortcuts.js
Normal file
74
static/js/core/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
|
||||
const KeyboardShortcuts = (function () {
|
||||
'use strict';
|
||||
|
||||
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
|
||||
let _handler = null;
|
||||
|
||||
function _handle(e) {
|
||||
if (e.target.matches(GUARD_SELECTOR)) return;
|
||||
|
||||
if (e.altKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||
case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break;
|
||||
case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break;
|
||||
case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||
case 's': e.preventDefault(); _toggleSidebar(); break;
|
||||
case 'k': e.preventDefault(); showHelp(); break;
|
||||
case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
|
||||
default:
|
||||
if (e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
_switchToNthMode(parseInt(e.key) - 1);
|
||||
}
|
||||
}
|
||||
} else if (!e.ctrlKey && !e.metaKey) {
|
||||
if (e.key === '?') { showHelp(); }
|
||||
if (e.key === 'Escape') {
|
||||
const kbModal = document.getElementById('kbShortcutsModal');
|
||||
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
|
||||
const csModal = document.getElementById('cheatSheetModal');
|
||||
if (csModal && csModal.style.display !== 'none') {
|
||||
window.CheatSheets && CheatSheets.hide(); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _toggleSidebar() {
|
||||
const mc = document.querySelector('.main-content');
|
||||
if (mc) mc.classList.toggle('sidebar-collapsed');
|
||||
}
|
||||
|
||||
function _switchToNthMode(n) {
|
||||
if (!window.interceptModeCatalog) return;
|
||||
const mode = document.body.getAttribute('data-mode');
|
||||
if (!mode) return;
|
||||
const catalog = window.interceptModeCatalog;
|
||||
const entry = catalog[mode];
|
||||
if (!entry) return;
|
||||
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
|
||||
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
const modal = document.getElementById('kbShortcutsModal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideHelp() {
|
||||
const modal = document.getElementById('kbShortcutsModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (_handler) document.removeEventListener('keydown', _handler);
|
||||
_handler = _handle;
|
||||
document.addEventListener('keydown', _handler);
|
||||
}
|
||||
|
||||
return { init, showHelp, hideHelp };
|
||||
})();
|
||||
|
||||
window.KeyboardShortcuts = KeyboardShortcuts;
|
||||
@@ -114,13 +114,7 @@ const RecordingUI = (function() {
|
||||
|
||||
function openReplay(sessionId) {
|
||||
if (!sessionId) return;
|
||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
||||
if (typeof hideSettings === 'function') hideSettings();
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('analytics', { updateUrl: true });
|
||||
return;
|
||||
}
|
||||
window.location.href = '/?mode=analytics';
|
||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
@@ -1265,6 +1265,7 @@ function switchSettingsTab(tabName) {
|
||||
} else if (tabName === 'location') {
|
||||
loadObserverLocation();
|
||||
} else if (tabName === 'alerts') {
|
||||
loadVoiceAlertConfig();
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.loadFeed();
|
||||
}
|
||||
@@ -1277,6 +1278,61 @@ function switchSettingsTab(tabName) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load voice alert configuration into Settings > Alerts tab
|
||||
*/
|
||||
function loadVoiceAlertConfig() {
|
||||
if (typeof VoiceAlerts === 'undefined') return;
|
||||
const cfg = VoiceAlerts.getConfig();
|
||||
|
||||
const pager = document.getElementById('voiceCfgPager');
|
||||
const tscm = document.getElementById('voiceCfgTscm');
|
||||
const tracker = document.getElementById('voiceCfgTracker');
|
||||
const squawk = document.getElementById('voiceCfgSquawk');
|
||||
const rate = document.getElementById('voiceCfgRate');
|
||||
const pitch = document.getElementById('voiceCfgPitch');
|
||||
const rateVal = document.getElementById('voiceCfgRateVal');
|
||||
const pitchVal = document.getElementById('voiceCfgPitchVal');
|
||||
|
||||
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 (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||
if (rate) rate.value = cfg.rate;
|
||||
if (pitch) pitch.value = cfg.pitch;
|
||||
if (rateVal) rateVal.textContent = cfg.rate;
|
||||
if (pitchVal) pitchVal.textContent = cfg.pitch;
|
||||
|
||||
// Populate voice dropdown
|
||||
VoiceAlerts.getAvailableVoices().then(function (voices) {
|
||||
var sel = document.getElementById('voiceCfgVoice');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Default</option>' +
|
||||
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
|
||||
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function saveVoiceAlertConfig() {
|
||||
if (typeof VoiceAlerts === 'undefined') return;
|
||||
VoiceAlerts.setConfig({
|
||||
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function testVoiceAlert() {
|
||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load API key status into the API Keys settings tab
|
||||
*/
|
||||
|
||||
200
static/js/core/voice-alerts.js
Normal file
200
static/js/core/voice-alerts.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||
const VoiceAlerts = (function () {
|
||||
'use strict';
|
||||
|
||||
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||
let _enabled = true;
|
||||
let _muted = false;
|
||||
let _queue = [];
|
||||
let _speaking = false;
|
||||
let _sources = {};
|
||||
const STORAGE_KEY = 'intercept-voice-muted';
|
||||
const CONFIG_KEY = 'intercept-voice-config';
|
||||
|
||||
// Default config
|
||||
let _config = {
|
||||
rate: 1.1,
|
||||
pitch: 0.9,
|
||||
voiceName: '',
|
||||
streams: { pager: true, tscm: true, bluetooth: true },
|
||||
};
|
||||
|
||||
function _loadConfig() {
|
||||
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
try {
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
_config.rate = parsed.rate ?? _config.rate;
|
||||
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||
if (parsed.streams) {
|
||||
Object.assign(_config.streams, parsed.streams);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
_updateMuteButton();
|
||||
}
|
||||
|
||||
function _updateMuteButton() {
|
||||
const btn = document.getElementById('voiceMuteBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('voice-muted', _muted);
|
||||
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||
btn.style.opacity = _muted ? '0.4' : '1';
|
||||
}
|
||||
|
||||
function _getVoice() {
|
||||
if (!_config.voiceName) return null;
|
||||
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||
return voices.find(v => v.name === _config.voiceName) || null;
|
||||
}
|
||||
|
||||
function speak(text, priority) {
|
||||
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||
if (!_enabled || _muted) return;
|
||||
if (!window.speechSynthesis) return;
|
||||
if (priority === PRIORITY.LOW && _speaking) return;
|
||||
if (priority === PRIORITY.HIGH && _speaking) {
|
||||
window.speechSynthesis.cancel();
|
||||
_queue = [];
|
||||
_speaking = false;
|
||||
}
|
||||
_queue.push({ text, priority });
|
||||
if (!_speaking) _dequeue();
|
||||
}
|
||||
|
||||
function _dequeue() {
|
||||
if (_queue.length === 0) { _speaking = false; return; }
|
||||
_speaking = true;
|
||||
const item = _queue.shift();
|
||||
const utt = new SpeechSynthesisUtterance(item.text);
|
||||
utt.rate = _config.rate;
|
||||
utt.pitch = _config.pitch;
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
_muted = !_muted;
|
||||
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||
_updateMuteButton();
|
||||
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
}
|
||||
|
||||
function _openStream(url, handler, key) {
|
||||
if (_sources[key]) return;
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = handler;
|
||||
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||
_sources[key] = es;
|
||||
}
|
||||
|
||||
function _startStreams() {
|
||||
if (!_enabled) return;
|
||||
|
||||
// Pager stream
|
||||
if (_config.streams.pager) {
|
||||
_openStream('/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.address && d.message) {
|
||||
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'pager');
|
||||
}
|
||||
|
||||
// TSCM stream
|
||||
if (_config.streams.tscm) {
|
||||
_openStream('/tscm/sweep/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.threat_level && d.description) {
|
||||
speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'tscm');
|
||||
}
|
||||
|
||||
// Bluetooth stream — tracker detection only
|
||||
if (_config.streams.bluetooth) {
|
||||
_openStream('/api/bluetooth/stream', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.service_data && d.service_data.tracker_type) {
|
||||
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 'bluetooth');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function _stopStreams() {
|
||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
_sources = {};
|
||||
}
|
||||
|
||||
function init() {
|
||||
_loadConfig();
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function setEnabled(val) {
|
||||
_enabled = val;
|
||||
if (!val) {
|
||||
_stopStreams();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
} else {
|
||||
_startStreams();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||
|
||||
function getConfig() {
|
||||
return JSON.parse(JSON.stringify(_config));
|
||||
}
|
||||
|
||||
function setConfig(cfg) {
|
||||
if (cfg.rate !== undefined) _config.rate = cfg.rate;
|
||||
if (cfg.pitch !== undefined) _config.pitch = cfg.pitch;
|
||||
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||
// Restart streams to apply per-stream toggle changes
|
||||
_stopStreams();
|
||||
_startStreams();
|
||||
}
|
||||
|
||||
function getAvailableVoices() {
|
||||
return new Promise(resolve => {
|
||||
if (!window.speechSynthesis) { resolve([]); return; }
|
||||
let voices = speechSynthesis.getVoices();
|
||||
if (voices.length > 0) { resolve(voices); return; }
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
resolve(speechSynthesis.getVoices());
|
||||
};
|
||||
// Timeout fallback
|
||||
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||
});
|
||||
}
|
||||
|
||||
function testVoice(text) {
|
||||
if (!window.speechSynthesis) return;
|
||||
const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.');
|
||||
utt.rate = _config.rate;
|
||||
utt.pitch = _config.pitch;
|
||||
const voice = _getVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
speechSynthesis.speak(utt);
|
||||
}
|
||||
|
||||
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||
})();
|
||||
|
||||
window.VoiceAlerts = VoiceAlerts;
|
||||
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* Analytics Dashboard Module
|
||||
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
||||
*/
|
||||
const Analytics = (function () {
|
||||
'use strict';
|
||||
|
||||
let refreshTimer = null;
|
||||
let replayTimer = null;
|
||||
let replaySessions = [];
|
||||
let replayEvents = [];
|
||||
let replayIndex = 0;
|
||||
|
||||
function init() {
|
||||
refresh();
|
||||
loadReplaySessions();
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(refresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
pauseReplay();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Promise.all([
|
||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
||||
if (summary) renderSummary(summary);
|
||||
if (activity) renderSparklines(activity.sparklines || {});
|
||||
if (insights) renderInsights(insights);
|
||||
if (patterns) renderPatterns(patterns.patterns || []);
|
||||
if (alerts) renderAlerts(alerts.events || []);
|
||||
if (correlations) renderCorrelations(correlations);
|
||||
if (geofences) renderGeofences(geofences.zones || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const counts = data.counts || {};
|
||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||
_setText('analyticsCountAis', counts.ais || 0);
|
||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||
_setText('analyticsCountAcars', counts.acars || 0);
|
||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||
|
||||
const health = data.health || {};
|
||||
const container = document.getElementById('analyticsHealth');
|
||||
if (container) {
|
||||
let html = '';
|
||||
const modeLabels = {
|
||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
||||
};
|
||||
for (const [mode, info] of Object.entries(health)) {
|
||||
if (mode === 'sdr_devices') continue;
|
||||
const running = info && info.running;
|
||||
const label = modeLabels[mode] || mode;
|
||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
const squawks = data.squawks || [];
|
||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||
const sqList = document.getElementById('analyticsSquawkList');
|
||||
if (sqSection && sqList) {
|
||||
if (squawks.length > 0) {
|
||||
sqSection.style.display = '';
|
||||
sqList.innerHTML = squawks.map(s =>
|
||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
||||
).join('');
|
||||
} else {
|
||||
sqSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSparklines(sparklines) {
|
||||
const map = {
|
||||
adsb: 'analyticsSparkAdsb',
|
||||
ais: 'analyticsSparkAis',
|
||||
wifi: 'analyticsSparkWifi',
|
||||
bluetooth: 'analyticsSparkBt',
|
||||
dsc: 'analyticsSparkDsc',
|
||||
acars: 'analyticsSparkAcars',
|
||||
vdl2: 'analyticsSparkVdl2',
|
||||
aprs: 'analyticsSparkAprs',
|
||||
meshtastic: 'analyticsSparkMesh',
|
||||
};
|
||||
|
||||
for (const [mode, elId] of Object.entries(map)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) continue;
|
||||
const data = sparklines[mode] || [];
|
||||
if (data.length < 2) {
|
||||
el.innerHTML = '';
|
||||
continue;
|
||||
}
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 100;
|
||||
const h = 24;
|
||||
const step = w / (data.length - 1);
|
||||
const points = data.map((v, i) =>
|
||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||
).join(' ');
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderInsights(data) {
|
||||
const cards = data.cards || [];
|
||||
const topChanges = data.top_changes || [];
|
||||
const cardsEl = document.getElementById('analyticsInsights');
|
||||
const changesEl = document.getElementById('analyticsTopChanges');
|
||||
|
||||
if (cardsEl) {
|
||||
if (!cards.length) {
|
||||
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
||||
} else {
|
||||
cardsEl.innerHTML = cards.map(c => {
|
||||
const sev = _esc(c.severity || 'low');
|
||||
const title = _esc(c.title || 'Insight');
|
||||
const value = _esc(c.value || '--');
|
||||
const label = _esc(c.label || '');
|
||||
const detail = _esc(c.detail || '');
|
||||
return '<div class="analytics-insight-card ' + sev + '">' +
|
||||
'<div class="insight-title">' + title + '</div>' +
|
||||
'<div class="insight-value">' + value + '</div>' +
|
||||
'<div class="insight-label">' + label + '</div>' +
|
||||
'<div class="insight-detail">' + detail + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
if (changesEl) {
|
||||
if (!topChanges.length) {
|
||||
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
||||
} else {
|
||||
changesEl.innerHTML = topChanges.map(item => {
|
||||
const mode = _esc(item.mode_label || item.mode || '');
|
||||
const deltaRaw = Number(item.delta || 0);
|
||||
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
||||
const delta = _esc(item.signed_delta || String(deltaRaw));
|
||||
const recentAvg = _esc(item.recent_avg);
|
||||
const prevAvg = _esc(item.previous_avg);
|
||||
return '<div class="analytics-change-row">' +
|
||||
'<span class="mode">' + mode + '</span>' +
|
||||
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
||||
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatterns(patterns) {
|
||||
const container = document.getElementById('analyticsPatternList');
|
||||
if (!container) return;
|
||||
if (!patterns || patterns.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const modeLabels = {
|
||||
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
||||
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
||||
};
|
||||
|
||||
const sorted = patterns
|
||||
.slice()
|
||||
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
||||
.slice(0, 20);
|
||||
|
||||
container.innerHTML = sorted.map(p => {
|
||||
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
||||
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
||||
const period = _humanPeriod(Number(p.period_seconds || 0));
|
||||
const occurrences = Number(p.occurrences || 0);
|
||||
const deviceId = _shortId(p.device_id || '--');
|
||||
return '<div class="analytics-pattern-item">' +
|
||||
'<div class="pattern-main">' +
|
||||
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
||||
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="pattern-meta">' +
|
||||
'<span>Period: ' + _esc(period) + '</span>' +
|
||||
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
||||
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAlerts(events) {
|
||||
const container = document.getElementById('analyticsAlertFeed');
|
||||
if (!container) return;
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = events.slice(0, 20).map(e => {
|
||||
const sev = e.severity || 'medium';
|
||||
const title = e.title || e.event_type || 'Alert';
|
||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||
return '<div class="analytics-alert-item">' +
|
||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||
'<span>' + _esc(title) + '</span>' +
|
||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderCorrelations(data) {
|
||||
const container = document.getElementById('analyticsCorrelations');
|
||||
if (!container) return;
|
||||
const pairs = (data && data.correlations) || [];
|
||||
if (pairs.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||
const conf = Math.round((p.confidence || 0) * 100);
|
||||
return '<div class="analytics-correlation-pair">' +
|
||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||
'<span style="color:var(--text-dim)">↔</span>' +
|
||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderGeofences(zones) {
|
||||
const container = document.getElementById('analyticsGeofenceList');
|
||||
if (!container) return;
|
||||
if (!zones || zones.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = zones.map(z =>
|
||||
'<div class="geofence-zone-item">' +
|
||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addGeofence() {
|
||||
const name = prompt('Zone name:');
|
||||
if (!name) return;
|
||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||
alert('Invalid input');
|
||||
return;
|
||||
}
|
||||
fetch('/analytics/geofences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function deleteGeofence(id) {
|
||||
if (!confirm('Delete this geofence zone?')) return;
|
||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function exportData(mode) {
|
||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||
}
|
||||
|
||||
function searchTarget() {
|
||||
const input = document.getElementById('analyticsTargetQuery');
|
||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
||||
const q = (input && input.value || '').trim();
|
||||
if (!q) {
|
||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
||||
renderTargetResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const results = data.results || [];
|
||||
if (summaryEl) {
|
||||
const modeCounts = data.mode_counts || {};
|
||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
||||
}
|
||||
renderTargetResults(results);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTargetResults(results) {
|
||||
const container = document.getElementById('analyticsTargetResults');
|
||||
if (!container) return;
|
||||
|
||||
if (!results || !results.length) {
|
||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = results.map((item) => {
|
||||
const title = _esc(item.title || item.id || 'Entity');
|
||||
const subtitle = _esc(item.subtitle || '');
|
||||
const mode = _esc(item.mode || 'unknown');
|
||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
||||
const lastSeen = _esc(item.last_seen || '');
|
||||
return '<div class="analytics-target-item">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadReplaySessions() {
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
if (!select) return;
|
||||
|
||||
fetch('/recordings?limit=60')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
||||
|
||||
if (!replaySessions.length) {
|
||||
select.innerHTML = '<option value="">No recordings</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = replaySessions.map((rec) => {
|
||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
||||
select.value = pendingReplay;
|
||||
localStorage.removeItem('analyticsReplaySession');
|
||||
loadReplay();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadReplay() {
|
||||
pauseReplay();
|
||||
replayEvents = [];
|
||||
replayIndex = 0;
|
||||
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
if (!select || !meta || !timeline) return;
|
||||
|
||||
const id = select.value;
|
||||
if (!id) {
|
||||
meta.textContent = 'Select a recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
meta.textContent = 'Loading replay events...';
|
||||
|
||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replayEvents = data.events || [];
|
||||
replayIndex = 0;
|
||||
if (!replayEvents.length) {
|
||||
meta.textContent = 'No events found in selected recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rec = replaySessions.find((s) => s.id === id);
|
||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
||||
renderReplayWindow();
|
||||
})
|
||||
.catch((err) => {
|
||||
meta.textContent = 'Replay load failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (replayTimer) return;
|
||||
|
||||
replayTimer = setInterval(() => {
|
||||
if (replayIndex >= replayEvents.length - 1) {
|
||||
pauseReplay();
|
||||
return;
|
||||
}
|
||||
replayIndex += 1;
|
||||
renderReplayWindow();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function pauseReplay() {
|
||||
if (replayTimer) {
|
||||
clearInterval(replayTimer);
|
||||
replayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stepReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
pauseReplay();
|
||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
||||
renderReplayWindow();
|
||||
}
|
||||
|
||||
function renderReplayWindow() {
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
if (!timeline || !meta) return;
|
||||
|
||||
const total = replayEvents.length;
|
||||
if (!total) {
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.max(0, replayIndex - 15);
|
||||
const end = Math.min(total, replayIndex + 20);
|
||||
const windowed = replayEvents.slice(start, end);
|
||||
|
||||
timeline.innerHTML = windowed.map((row, i) => {
|
||||
const absolute = start + i;
|
||||
const active = absolute === replayIndex;
|
||||
const eventType = _esc(row.event_type || 'event');
|
||||
const mode = _esc(row.mode || '--');
|
||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
||||
const detail = summarizeReplayEvent(row.event || {});
|
||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
||||
}
|
||||
|
||||
function summarizeReplayEvent(event) {
|
||||
if (!event || typeof event !== 'object') return 'No details';
|
||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
||||
if (event.icao) return `ICAO ${event.icao}`;
|
||||
if (event.ssid) return `SSID ${event.ssid}`;
|
||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
||||
if (event.address) return `Address ${event.address}`;
|
||||
if (event.name) return `Name ${event.name}`;
|
||||
const keys = Object.keys(event);
|
||||
if (!keys.length) return 'No fields';
|
||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
||||
}
|
||||
|
||||
function _setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _shortId(value) {
|
||||
const text = String(value || '');
|
||||
if (text.length <= 18) return text;
|
||||
return text.slice(0, 8) + '...' + text.slice(-6);
|
||||
}
|
||||
|
||||
function _humanPeriod(seconds) {
|
||||
if (!isFinite(seconds) || seconds <= 0) return '--';
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
const mins = seconds / 60;
|
||||
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
||||
const hours = mins / 60;
|
||||
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
destroy,
|
||||
refresh,
|
||||
addGeofence,
|
||||
deleteGeofence,
|
||||
exportData,
|
||||
searchTarget,
|
||||
loadReplay,
|
||||
playReplay,
|
||||
pauseReplay,
|
||||
stepReplay,
|
||||
loadReplaySessions,
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
404
static/js/modes/fingerprint.js
Normal file
404
static/js/modes/fingerprint.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/* Signal Fingerprinting — RF baseline recorder + anomaly comparator */
|
||||
const Fingerprint = (function () {
|
||||
'use strict';
|
||||
|
||||
let _active = false;
|
||||
let _recording = false;
|
||||
let _scannerSource = null;
|
||||
let _pendingObs = [];
|
||||
let _flushTimer = null;
|
||||
let _currentTab = 'record';
|
||||
let _chartInstance = null;
|
||||
let _ownedScanner = false;
|
||||
let _obsCount = 0;
|
||||
|
||||
function _flushObservations() {
|
||||
if (!_recording || _pendingObs.length === 0) return;
|
||||
const batch = _pendingObs.splice(0);
|
||||
fetch('/fingerprint/observation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ observations: batch }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function _startScannerStream() {
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
_scannerSource = new EventSource('/listening/scanner/stream');
|
||||
_scannerSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
// Only collect meaningful signal events (signal_found has SNR)
|
||||
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
|
||||
|
||||
const freq = d.frequency ?? d.freq_mhz ?? null;
|
||||
if (freq === null) return;
|
||||
|
||||
// Prefer SNR (dB) from signal_found events; fall back to level for scan_update
|
||||
let power = null;
|
||||
if (d.snr !== undefined && d.snr !== null) {
|
||||
power = d.snr;
|
||||
} else if (d.level !== undefined && d.level !== null) {
|
||||
// level is RMS audio — skip scan_update noise floor readings
|
||||
if (d.type === 'signal_found') {
|
||||
power = d.level;
|
||||
} else {
|
||||
return; // scan_update with no SNR — skip
|
||||
}
|
||||
} else if (d.power_dbm !== undefined) {
|
||||
power = d.power_dbm;
|
||||
}
|
||||
|
||||
if (power === null) return;
|
||||
|
||||
if (_recording) {
|
||||
_pendingObs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
|
||||
_obsCount++;
|
||||
_updateObsCounter();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
function _updateObsCounter() {
|
||||
const el = document.getElementById('fpObsCount');
|
||||
if (el) el.textContent = _obsCount;
|
||||
}
|
||||
|
||||
function _setStatus(msg) {
|
||||
const el = document.getElementById('fpRecordStatus');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Scanner lifecycle (standalone control) ─────────────────────────
|
||||
|
||||
async function _checkScannerStatus() {
|
||||
try {
|
||||
const r = await fetch('/listening/scanner/status');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
return !!d.running;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _updateScannerStatusUI() {
|
||||
const running = await _checkScannerStatus();
|
||||
const dotEl = document.getElementById('fpScannerDot');
|
||||
const textEl = document.getElementById('fpScannerStatusText');
|
||||
const startB = document.getElementById('fpScannerStartBtn');
|
||||
const stopB = document.getElementById('fpScannerStopBtn');
|
||||
|
||||
if (dotEl) dotEl.style.background = running ? 'var(--accent-green, #00ff88)' : 'rgba(255,255,255,0.2)';
|
||||
if (textEl) textEl.textContent = running ? 'Scanner running' : 'Scanner not running';
|
||||
if (startB) startB.style.display = running ? 'none' : '';
|
||||
if (stopB) stopB.style.display = (running && _ownedScanner) ? '' : 'none';
|
||||
|
||||
// Auto-connect to stream if scanner is running
|
||||
if (running && !_scannerSource) _startScannerStream();
|
||||
}
|
||||
|
||||
async function startScanner() {
|
||||
const deviceVal = document.getElementById('fpDevice')?.value || 'rtlsdr:0';
|
||||
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
|
||||
const startB = document.getElementById('fpScannerStartBtn');
|
||||
if (startB) { startB.disabled = true; startB.textContent = 'Starting…'; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/listening/scanner/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_freq: 24, end_freq: 1700, sdr_type: sdrType, device: parseInt(idxStr) || 0 }),
|
||||
});
|
||||
if (res.ok) {
|
||||
_ownedScanner = true;
|
||||
_startScannerStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (startB) { startB.disabled = false; startB.textContent = 'Start Scanner'; }
|
||||
await _updateScannerStatusUI();
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (!_ownedScanner) return;
|
||||
try {
|
||||
await fetch('/listening/scanner/stop', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
_ownedScanner = false;
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
await _updateScannerStatusUI();
|
||||
}
|
||||
|
||||
// ── Recording ──────────────────────────────────────────────────────
|
||||
|
||||
async function startRecording() {
|
||||
// Check scanner is running first
|
||||
const running = await _checkScannerStatus();
|
||||
if (!running) {
|
||||
_setStatus('Scanner not running — start it first (Step 2)');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('fpSessionName')?.value.trim() || 'Session ' + new Date().toLocaleString();
|
||||
const location = document.getElementById('fpSessionLocation')?.value.trim() || null;
|
||||
try {
|
||||
const res = await fetch('/fingerprint/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, location }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Start failed');
|
||||
_recording = true;
|
||||
_pendingObs = [];
|
||||
_obsCount = 0;
|
||||
_updateObsCounter();
|
||||
_flushTimer = setInterval(_flushObservations, 5000);
|
||||
if (!_scannerSource) _startScannerStream();
|
||||
const startBtn = document.getElementById('fpStartBtn');
|
||||
const stopBtn = document.getElementById('fpStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = '';
|
||||
_setStatus('Recording… session #' + data.session_id);
|
||||
} catch (e) {
|
||||
_setStatus('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
_recording = false;
|
||||
_flushObservations();
|
||||
if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; }
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
try {
|
||||
const res = await fetch('/fingerprint/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
_setStatus(`Saved: ${data.bands_recorded} bands recorded (${_obsCount} observations)`);
|
||||
} catch (e) {
|
||||
_setStatus('Error saving: ' + e.message);
|
||||
}
|
||||
const startBtn = document.getElementById('fpStartBtn');
|
||||
const stopBtn = document.getElementById('fpStopBtn');
|
||||
if (startBtn) startBtn.style.display = '';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
async function _loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/fingerprint/list');
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById('fpBaselineSelect');
|
||||
if (!sel) return;
|
||||
const sessions = (data.sessions || []).filter(s => s.finalized_at);
|
||||
sel.innerHTML = sessions.length
|
||||
? sessions.map(s => `<option value="${s.id}">[${s.id}] ${s.name} (${s.band_count || 0} bands)</option>`).join('')
|
||||
: '<option value="">No saved baselines</option>';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Compare ────────────────────────────────────────────────────────
|
||||
|
||||
async function compareNow() {
|
||||
const baselineId = document.getElementById('fpBaselineSelect')?.value;
|
||||
if (!baselineId) return;
|
||||
|
||||
// Check scanner is running
|
||||
const running = await _checkScannerStatus();
|
||||
if (!running) {
|
||||
const statusEl = document.getElementById('fpCompareStatus');
|
||||
if (statusEl) statusEl.textContent = 'Scanner not running — start it first';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('fpCompareStatus');
|
||||
const compareBtn = document.querySelector('#fpComparePanel .run-btn');
|
||||
if (statusEl) statusEl.textContent = 'Collecting observations…';
|
||||
if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = 'Scanning…'; }
|
||||
|
||||
// Collect live observations for ~3 seconds
|
||||
const obs = [];
|
||||
const tmpSrc = new EventSource('/listening/scanner/stream');
|
||||
const deadline = Date.now() + 3000;
|
||||
|
||||
await new Promise(resolve => {
|
||||
tmpSrc.onmessage = (ev) => {
|
||||
if (Date.now() > deadline) { tmpSrc.close(); resolve(); return; }
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return;
|
||||
const freq = d.frequency ?? d.freq_mhz ?? null;
|
||||
let power = null;
|
||||
if (d.snr !== undefined && d.snr !== null) power = d.snr;
|
||||
else if (d.type === 'signal_found' && d.level !== undefined) power = d.level;
|
||||
else if (d.power_dbm !== undefined) power = d.power_dbm;
|
||||
if (freq !== null && power !== null) obs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) });
|
||||
if (statusEl) statusEl.textContent = `Collecting… ${obs.length} observations`;
|
||||
} catch (_) {}
|
||||
};
|
||||
tmpSrc.onerror = () => { tmpSrc.close(); resolve(); };
|
||||
setTimeout(() => { tmpSrc.close(); resolve(); }, 3500);
|
||||
});
|
||||
|
||||
if (statusEl) statusEl.textContent = `Comparing ${obs.length} observations against baseline…`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/fingerprint/compare', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseline_id: parseInt(baselineId), observations: obs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
_renderAnomalies(data.anomalies || []);
|
||||
_renderChart(data.baseline_bands || [], data.anomalies || []);
|
||||
if (statusEl) statusEl.textContent = `Done — ${obs.length} observations, ${(data.anomalies || []).length} anomalies`;
|
||||
} catch (e) {
|
||||
console.error('Compare failed:', e);
|
||||
if (statusEl) statusEl.textContent = 'Compare failed: ' + e.message;
|
||||
}
|
||||
|
||||
if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = 'Compare Now'; }
|
||||
}
|
||||
|
||||
function _renderAnomalies(anomalies) {
|
||||
const panel = document.getElementById('fpAnomalyList');
|
||||
const items = document.getElementById('fpAnomalyItems');
|
||||
if (!panel || !items) return;
|
||||
|
||||
if (anomalies.length === 0) {
|
||||
items.innerHTML = '<div style="font-size:11px; color:var(--text-dim); padding:8px;">No significant anomalies detected.</div>';
|
||||
panel.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
items.innerHTML = anomalies.map(a => {
|
||||
const z = a.z_score !== null ? Math.abs(a.z_score) : 999;
|
||||
let cls = 'severity-warn', badge = 'POWER';
|
||||
if (a.anomaly_type === 'new') { cls = 'severity-new'; badge = 'NEW'; }
|
||||
else if (a.anomaly_type === 'missing') { cls = 'severity-warn'; badge = 'MISSING'; }
|
||||
else if (z >= 3) { cls = 'severity-alert'; }
|
||||
|
||||
const zText = a.z_score !== null ? `z=${a.z_score.toFixed(1)}` : '';
|
||||
const powerText = a.current_power !== null ? `${a.current_power.toFixed(1)} dBm` : 'absent';
|
||||
const baseText = a.baseline_mean !== null ? `baseline: ${a.baseline_mean.toFixed(1)} dBm` : '';
|
||||
|
||||
return `<div class="fp-anomaly-item ${cls}">
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<span class="fp-anomaly-band">${a.band_label}</span>
|
||||
<span class="fp-anomaly-type-badge" style="background:rgba(255,255,255,0.1);">${badge}</span>
|
||||
${z >= 3 ? '<span style="color:#ef4444; font-size:9px; font-weight:700;">ALERT</span>' : ''}
|
||||
</div>
|
||||
<div style="color:var(--text-secondary);">${powerText} ${baseText} ${zText}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
panel.style.display = 'block';
|
||||
|
||||
// Voice alert for high-severity anomalies
|
||||
const highZ = anomalies.find(a => (a.z_score !== null && Math.abs(a.z_score) >= 3) || a.anomaly_type === 'new');
|
||||
if (highZ && window.VoiceAlerts) {
|
||||
VoiceAlerts.speak(`RF anomaly detected: ${highZ.band_label} — ${highZ.anomaly_type}`, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderChart(baselineBands, anomalies) {
|
||||
const canvas = document.getElementById('fpChartCanvas');
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
|
||||
const anomalyMap = {};
|
||||
anomalies.forEach(a => { anomalyMap[a.band_center_mhz] = a; });
|
||||
|
||||
const bands = baselineBands.slice(0, 40);
|
||||
const labels = bands.map(b => b.band_center_mhz.toFixed(1));
|
||||
const means = bands.map(b => b.mean_dbm);
|
||||
const currentPowers = bands.map(b => {
|
||||
const a = anomalyMap[b.band_center_mhz];
|
||||
return a ? a.current_power : b.mean_dbm;
|
||||
});
|
||||
const barColors = bands.map(b => {
|
||||
const a = anomalyMap[b.band_center_mhz];
|
||||
if (!a) return 'rgba(74,163,255,0.6)';
|
||||
if (a.anomaly_type === 'new') return 'rgba(168,85,247,0.8)';
|
||||
if (a.z_score !== null && Math.abs(a.z_score) >= 3) return 'rgba(239,68,68,0.8)';
|
||||
return 'rgba(251,191,36,0.7)';
|
||||
});
|
||||
|
||||
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
|
||||
|
||||
_chartInstance = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Baseline Mean', data: means, backgroundColor: 'rgba(74,163,255,0.3)', borderColor: 'rgba(74,163,255,0.8)', borderWidth: 1 },
|
||||
{ label: 'Current', data: currentPowers, backgroundColor: barColors, borderColor: barColors, borderWidth: 1 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#aaa', font: { size: 10 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#666', font: { size: 9 }, maxRotation: 90 }, grid: { color: 'rgba(255,255,255,0.05)' } },
|
||||
y: { ticks: { color: '#666', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: 'Power (dBm)', color: '#666' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
_currentTab = tab;
|
||||
const recordPanel = document.getElementById('fpRecordPanel');
|
||||
const comparePanel = document.getElementById('fpComparePanel');
|
||||
if (recordPanel) recordPanel.style.display = tab === 'record' ? '' : 'none';
|
||||
if (comparePanel) comparePanel.style.display = tab === 'compare' ? '' : 'none';
|
||||
document.querySelectorAll('.fp-tab-btn').forEach(b => b.classList.remove('active'));
|
||||
const activeBtn = tab === 'record'
|
||||
? document.getElementById('fpTabRecord')
|
||||
: document.getElementById('fpTabCompare');
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
const hintEl = document.getElementById('fpTabHint');
|
||||
if (hintEl) hintEl.innerHTML = TAB_HINTS[tab] || '';
|
||||
if (tab === 'compare') _loadSessions();
|
||||
}
|
||||
|
||||
function _loadDevices() {
|
||||
const sel = document.getElementById('fpDevice');
|
||||
if (!sel) return;
|
||||
fetch('/devices').then(r => r.json()).then(devices => {
|
||||
if (!devices || devices.length === 0) {
|
||||
sel.innerHTML = '<option value="">No SDR devices detected</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = devices.map(d => {
|
||||
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
|
||||
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
|
||||
}).join('');
|
||||
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
|
||||
}
|
||||
|
||||
const TAB_HINTS = {
|
||||
record: 'Record a <strong style="color:var(--text-secondary);">baseline</strong> in a known-clean RF environment, then use <strong style="color:var(--text-secondary);">Compare</strong> later to detect new or anomalous signals.',
|
||||
compare: 'Select a saved baseline and click <strong style="color:var(--text-secondary);">Compare Now</strong> to scan for deviations. Anomalies are flagged by statistical z-score.',
|
||||
};
|
||||
|
||||
function init() {
|
||||
_active = true;
|
||||
_loadDevices();
|
||||
_loadSessions();
|
||||
_updateScannerStatusUI();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_active = false;
|
||||
if (_recording) stopRecording();
|
||||
if (_scannerSource) { _scannerSource.close(); _scannerSource = null; }
|
||||
if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
|
||||
if (_ownedScanner) stopScanner();
|
||||
}
|
||||
|
||||
return { init, destroy, showTab, startRecording, stopRecording, compareNow, startScanner, stopScanner };
|
||||
})();
|
||||
|
||||
window.Fingerprint = Fingerprint;
|
||||
456
static/js/modes/rfheatmap.js
Normal file
456
static/js/modes/rfheatmap.js
Normal file
@@ -0,0 +1,456 @@
|
||||
/* RF Heatmap — GPS + signal strength Leaflet heatmap */
|
||||
const RFHeatmap = (function () {
|
||||
'use strict';
|
||||
|
||||
let _map = null;
|
||||
let _heatLayer = null;
|
||||
let _gpsSource = null;
|
||||
let _sigSource = null;
|
||||
let _heatPoints = [];
|
||||
let _isRecording = false;
|
||||
let _lastLat = null, _lastLng = null;
|
||||
let _minDist = 5;
|
||||
let _source = 'wifi';
|
||||
let _gpsPos = null;
|
||||
let _lastSignal = null;
|
||||
let _active = false;
|
||||
let _ownedSource = false; // true if heatmap started the source itself
|
||||
|
||||
const RSSI_RANGES = {
|
||||
wifi: { min: -90, max: -30 },
|
||||
bluetooth: { min: -100, max: -40 },
|
||||
scanner: { min: -120, max: -20 },
|
||||
};
|
||||
|
||||
function _norm(val, src) {
|
||||
const r = RSSI_RANGES[src] || RSSI_RANGES.wifi;
|
||||
return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min)));
|
||||
}
|
||||
|
||||
function _haversineM(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function _ensureLeafletHeat(cb) {
|
||||
if (window.L && L.heatLayer) { cb(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = '/static/js/vendor/leaflet-heat.js';
|
||||
s.onload = cb;
|
||||
s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _initMap() {
|
||||
if (_map) return;
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (!el) return;
|
||||
|
||||
// Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError)
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
|
||||
setTimeout(_initMap, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = _getFallbackPos();
|
||||
const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749);
|
||||
const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194);
|
||||
|
||||
_map = L.map(el, { zoomControl: true }).setView([lat, lng], 16);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 20,
|
||||
}).addTo(_map);
|
||||
|
||||
_heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map);
|
||||
}
|
||||
|
||||
function _startGPS() {
|
||||
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
|
||||
_gpsSource = new EventSource('/gps/stream');
|
||||
_gpsSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.lat && d.lng && d.fix) {
|
||||
_gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
|
||||
_updateGpsPill(true, _gpsPos.lat, _gpsPos.lng);
|
||||
if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false });
|
||||
} else {
|
||||
_updateGpsPill(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
_gpsSource.onerror = () => _updateGpsPill(false);
|
||||
}
|
||||
|
||||
function _updateGpsPill(fix, lat, lng) {
|
||||
const pill = document.getElementById('rfhmGpsPill');
|
||||
if (!pill) return;
|
||||
if (fix && lat !== undefined) {
|
||||
pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||
pill.style.color = 'var(--accent-green, #00ff88)';
|
||||
} else {
|
||||
const fallback = _getFallbackPos();
|
||||
pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix';
|
||||
pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)';
|
||||
}
|
||||
}
|
||||
|
||||
function _startSignalStream() {
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
let url;
|
||||
if (_source === 'wifi') url = '/wifi/stream';
|
||||
else if (_source === 'bluetooth') url = '/api/bluetooth/stream';
|
||||
else url = '/listening/scanner/stream';
|
||||
|
||||
_sigSource = new EventSource(url);
|
||||
_sigSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
let rssi = null;
|
||||
if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null;
|
||||
else if (_source === 'bluetooth') rssi = d.rssi ?? null;
|
||||
else rssi = d.power_level ?? d.power ?? null;
|
||||
if (rssi !== null) {
|
||||
_lastSignal = parseFloat(rssi);
|
||||
_updateSignalDisplay(_lastSignal);
|
||||
}
|
||||
_maybeSample();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
function _maybeSample() {
|
||||
if (!_isRecording || _lastSignal === null) return;
|
||||
if (!_gpsPos) {
|
||||
const fb = _getFallbackPos();
|
||||
if (fb) _gpsPos = fb;
|
||||
else return;
|
||||
}
|
||||
|
||||
const { lat, lng } = _gpsPos;
|
||||
if (_lastLat !== null) {
|
||||
const dist = _haversineM(_lastLat, _lastLng, lat, lng);
|
||||
if (dist < _minDist) return;
|
||||
}
|
||||
|
||||
const intensity = _norm(_lastSignal, _source);
|
||||
_heatPoints.push([lat, lng, intensity]);
|
||||
_lastLat = lat;
|
||||
_lastLng = lng;
|
||||
|
||||
if (_heatLayer) {
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints);
|
||||
}
|
||||
_updateCount();
|
||||
}
|
||||
|
||||
function _updateCount() {
|
||||
const el = document.getElementById('rfhmPointCount');
|
||||
if (el) el.textContent = _heatPoints.length;
|
||||
}
|
||||
|
||||
function _updateSignalDisplay(rssi) {
|
||||
const valEl = document.getElementById('rfhmLiveSignal');
|
||||
const barEl = document.getElementById('rfhmSignalBar');
|
||||
const statusEl = document.getElementById('rfhmSignalStatus');
|
||||
if (!valEl) return;
|
||||
|
||||
valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm';
|
||||
|
||||
if (rssi !== null) {
|
||||
// Normalise to 0–100% for the bar
|
||||
const pct = Math.round(_norm(rssi, _source) * 100);
|
||||
if (barEl) barEl.style.width = pct + '%';
|
||||
|
||||
// Colour the value by strength
|
||||
let color, label;
|
||||
if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; }
|
||||
else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; }
|
||||
else { color = '#f59e0b'; label = 'Weak'; }
|
||||
valEl.style.color = color;
|
||||
if (barEl) barEl.style.background = color;
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = _isRecording
|
||||
? `${label} — recording point every ${_minDist}m`
|
||||
: `${label} — press Start Recording to begin`;
|
||||
}
|
||||
} else {
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
valEl.style.color = 'var(--text-dim)';
|
||||
if (statusEl) statusEl.textContent = 'No signal data received yet';
|
||||
}
|
||||
}
|
||||
|
||||
function setSource(src) {
|
||||
_source = src;
|
||||
if (_active) _startSignalStream();
|
||||
}
|
||||
|
||||
function setMinDist(m) {
|
||||
_minDist = m;
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
_isRecording = true;
|
||||
_lastLat = null; _lastLng = null;
|
||||
const startBtn = document.getElementById('rfhmRecordBtn');
|
||||
const stopBtn = document.getElementById('rfhmStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); }
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
_isRecording = false;
|
||||
const startBtn = document.getElementById('rfhmRecordBtn');
|
||||
const stopBtn = document.getElementById('rfhmStopBtn');
|
||||
if (startBtn) startBtn.style.display = '';
|
||||
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); }
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
_heatPoints = [];
|
||||
if (_heatLayer) {
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]);
|
||||
}
|
||||
_updateCount();
|
||||
}
|
||||
|
||||
function exportGeoJSON() {
|
||||
const features = _heatPoints.map(([lat, lng, intensity]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||
properties: { intensity, source: _source },
|
||||
}));
|
||||
const geojson = { type: 'FeatureCollection', features };
|
||||
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rf_heatmap_${Date.now()}.geojson`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function invalidateMap() {
|
||||
if (!_map) return;
|
||||
const el = document.getElementById('rfheatmapMapEl');
|
||||
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
|
||||
_map.invalidateSize();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source lifecycle (start / stop / status) ──────────────────────
|
||||
|
||||
async function _checkSourceStatus() {
|
||||
const src = _source;
|
||||
let running = false;
|
||||
let detail = null;
|
||||
try {
|
||||
if (src === 'wifi') {
|
||||
const r = await fetch('/wifi/v2/scan/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; }
|
||||
} else if (src === 'bluetooth') {
|
||||
const r = await fetch('/api/bluetooth/scan/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.is_scanning; }
|
||||
} else if (src === 'scanner') {
|
||||
const r = await fetch('/listening/scanner/status');
|
||||
if (r.ok) { const d = await r.json(); running = !!d.running; }
|
||||
}
|
||||
} catch (_) {}
|
||||
return { running, detail };
|
||||
}
|
||||
|
||||
async function startSource() {
|
||||
const src = _source;
|
||||
const btn = document.getElementById('rfhmSourceStartBtn');
|
||||
const status = document.getElementById('rfhmSourceStatus');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (src === 'wifi') {
|
||||
// Try to find a monitor interface from the WiFi status first
|
||||
let iface = null;
|
||||
try {
|
||||
const st = await fetch('/wifi/v2/scan/status');
|
||||
if (st.ok) { const d = await st.json(); iface = d.interface || null; }
|
||||
} catch (_) {}
|
||||
if (!iface) {
|
||||
// Ask the user to enter an interface name
|
||||
const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):');
|
||||
if (!entered) { _updateSourceStatusUI(); return; }
|
||||
iface = entered.trim();
|
||||
}
|
||||
res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) });
|
||||
} else if (src === 'bluetooth') {
|
||||
res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) });
|
||||
} else if (src === 'scanner') {
|
||||
const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0';
|
||||
const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0'];
|
||||
res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) });
|
||||
}
|
||||
if (res && res.ok) {
|
||||
_ownedSource = true;
|
||||
_startSignalStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
await _updateSourceStatusUI();
|
||||
}
|
||||
|
||||
async function stopSource() {
|
||||
if (!_ownedSource) return;
|
||||
try {
|
||||
if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' });
|
||||
else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
||||
else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
_ownedSource = false;
|
||||
await _updateSourceStatusUI();
|
||||
}
|
||||
|
||||
async function _updateSourceStatusUI() {
|
||||
const { running, detail } = await _checkSourceStatus();
|
||||
const row = document.getElementById('rfhmSourceStatusRow');
|
||||
const dotEl = document.getElementById('rfhmSourceDot');
|
||||
const textEl = document.getElementById('rfhmSourceStatusText');
|
||||
const startB = document.getElementById('rfhmSourceStartBtn');
|
||||
const stopB = document.getElementById('rfhmSourceStopBtn');
|
||||
if (!row) return;
|
||||
|
||||
const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' };
|
||||
const name = SOURCE_NAMES[_source] || _source;
|
||||
|
||||
if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)';
|
||||
if (textEl) textEl.textContent = running
|
||||
? `${name} running${detail ? ' · ' + detail : ''}`
|
||||
: `${name} not running`;
|
||||
if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; }
|
||||
if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none';
|
||||
|
||||
// Auto-subscribe to stream if source just became running
|
||||
if (running && !_sigSource) _startSignalStream();
|
||||
}
|
||||
|
||||
const SOURCE_HINTS = {
|
||||
wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.',
|
||||
bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.',
|
||||
scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.',
|
||||
};
|
||||
|
||||
function onSourceChange() {
|
||||
const src = document.getElementById('rfhmSource')?.value || 'wifi';
|
||||
const hint = document.getElementById('rfhmSourceHint');
|
||||
const dg = document.getElementById('rfhmDeviceGroup');
|
||||
if (hint) hint.textContent = SOURCE_HINTS[src] || '';
|
||||
if (dg) dg.style.display = src === 'scanner' ? '' : 'none';
|
||||
_lastSignal = null;
|
||||
_ownedSource = false;
|
||||
_updateSignalDisplay(null);
|
||||
_updateSourceStatusUI();
|
||||
// Re-subscribe to correct stream
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
_startSignalStream();
|
||||
}
|
||||
|
||||
function _loadDevices() {
|
||||
const sel = document.getElementById('rfhmDevice');
|
||||
if (!sel) return;
|
||||
fetch('/devices').then(r => r.json()).then(devices => {
|
||||
if (!devices || devices.length === 0) {
|
||||
sel.innerHTML = '<option value="">No SDR devices detected</option>';
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = devices.map(d => {
|
||||
const label = d.serial ? `${d.name} [${d.serial}]` : d.name;
|
||||
return `<option value="${d.sdr_type}:${d.index}">${label}</option>`;
|
||||
}).join('');
|
||||
}).catch(() => { sel.innerHTML = '<option value="">Could not load devices</option>'; });
|
||||
}
|
||||
|
||||
function _getFallbackPos() {
|
||||
// Try observer location from localStorage (shared across all map modes)
|
||||
try {
|
||||
const stored = localStorage.getItem('observerLocation');
|
||||
if (stored) {
|
||||
const p = JSON.parse(stored);
|
||||
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
|
||||
return { lat: p.lat, lng: p.lon };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
// Try manual coord inputs
|
||||
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
|
||||
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
|
||||
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
|
||||
return null;
|
||||
}
|
||||
|
||||
function setManualCoords() {
|
||||
const lat = parseFloat(document.getElementById('rfhmManualLat')?.value);
|
||||
const lng = parseFloat(document.getElementById('rfhmManualLon')?.value);
|
||||
if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) {
|
||||
_map.setView([lat, lng], _map.getZoom(), { animate: false });
|
||||
}
|
||||
}
|
||||
|
||||
function useObserverLocation() {
|
||||
try {
|
||||
const stored = localStorage.getItem('observerLocation');
|
||||
if (stored) {
|
||||
const p = JSON.parse(stored);
|
||||
if (p && typeof p.lat === 'number' && typeof p.lon === 'number') {
|
||||
const latEl = document.getElementById('rfhmManualLat');
|
||||
const lonEl = document.getElementById('rfhmManualLon');
|
||||
if (latEl) latEl.value = p.lat.toFixed(5);
|
||||
if (lonEl) lonEl.value = p.lon.toFixed(5);
|
||||
if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function init() {
|
||||
_active = true;
|
||||
_loadDevices();
|
||||
onSourceChange();
|
||||
|
||||
// Pre-fill manual coords from observer location if available
|
||||
const fallback = _getFallbackPos();
|
||||
if (fallback) {
|
||||
const latEl = document.getElementById('rfhmManualLat');
|
||||
const lonEl = document.getElementById('rfhmManualLon');
|
||||
if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5);
|
||||
if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5);
|
||||
}
|
||||
|
||||
_updateSignalDisplay(null);
|
||||
_updateSourceStatusUI();
|
||||
_ensureLeafletHeat(() => {
|
||||
setTimeout(() => {
|
||||
_initMap();
|
||||
_startGPS();
|
||||
_startSignalStream();
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_active = false;
|
||||
if (_isRecording) stopRecording();
|
||||
if (_ownedSource) stopSource();
|
||||
if (_gpsSource) { _gpsSource.close(); _gpsSource = null; }
|
||||
if (_sigSource) { _sigSource.close(); _sigSource = null; }
|
||||
}
|
||||
|
||||
return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource };
|
||||
})();
|
||||
|
||||
window.RFHeatmap = RFHeatmap;
|
||||
2134
static/js/modes/waterfall.js
Normal file
2134
static/js/modes/waterfall.js
Normal file
File diff suppressed because it is too large
Load Diff
297
static/js/vendor/leaflet-heat.js
vendored
Normal file
297
static/js/vendor/leaflet-heat.js
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Leaflet.heat — a tiny, fast Leaflet heatmap plugin
|
||||
* https://github.com/Leaflet/Leaflet.heat
|
||||
* (c) 2014, Vladimir Agafonkin
|
||||
* MIT License
|
||||
*
|
||||
* Bundled local copy for INTERCEPT — avoids CDN dependency.
|
||||
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
|
||||
*/
|
||||
|
||||
// ---- simpleheat ----
|
||||
(function (global, factory) {
|
||||
typeof define === 'function' && define.amd ? define(factory) :
|
||||
typeof exports !== 'undefined' ? module.exports = factory() :
|
||||
global.simpleheat = factory();
|
||||
}(this, function () {
|
||||
'use strict';
|
||||
|
||||
function simpleheat(canvas) {
|
||||
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
|
||||
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
|
||||
this._ctx = canvas.getContext('2d');
|
||||
this._width = canvas.width;
|
||||
this._height = canvas.height;
|
||||
this._max = 1;
|
||||
this._data = [];
|
||||
}
|
||||
|
||||
simpleheat.prototype = {
|
||||
defaultRadius: 25,
|
||||
defaultGradient: {
|
||||
0.4: 'blue',
|
||||
0.6: 'cyan',
|
||||
0.7: 'lime',
|
||||
0.8: 'yellow',
|
||||
1.0: 'red'
|
||||
},
|
||||
|
||||
data: function (data) {
|
||||
this._data = data;
|
||||
return this;
|
||||
},
|
||||
|
||||
max: function (max) {
|
||||
this._max = max;
|
||||
return this;
|
||||
},
|
||||
|
||||
add: function (point) {
|
||||
this._data.push(point);
|
||||
return this;
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
this._data = [];
|
||||
return this;
|
||||
},
|
||||
|
||||
radius: function (r, blur) {
|
||||
blur = blur === undefined ? 15 : blur;
|
||||
var circle = this._circle = this._createCanvas(),
|
||||
ctx = circle.getContext('2d'),
|
||||
r2 = this._r = r + blur;
|
||||
circle.width = circle.height = r2 * 2;
|
||||
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
|
||||
ctx.shadowBlur = blur;
|
||||
ctx.shadowColor = 'black';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
return this;
|
||||
},
|
||||
|
||||
resize: function () {
|
||||
this._width = this._canvas.width;
|
||||
this._height = this._canvas.height;
|
||||
},
|
||||
|
||||
gradient: function (grad) {
|
||||
var canvas = this._createCanvas(),
|
||||
ctx = canvas.getContext('2d'),
|
||||
gradient = ctx.createLinearGradient(0, 0, 0, 256);
|
||||
canvas.width = 1;
|
||||
canvas.height = 256;
|
||||
for (var i in grad) {
|
||||
gradient.addColorStop(+i, grad[i]);
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1, 256);
|
||||
this._grad = ctx.getImageData(0, 0, 1, 256).data;
|
||||
return this;
|
||||
},
|
||||
|
||||
draw: function (minOpacity) {
|
||||
if (!this._circle) this.radius(this.defaultRadius);
|
||||
if (!this._grad) this.gradient(this.defaultGradient);
|
||||
|
||||
var ctx = this._ctx;
|
||||
ctx.clearRect(0, 0, this._width, this._height);
|
||||
|
||||
for (var i = 0, len = this._data.length, p; i < len; i++) {
|
||||
p = this._data[i];
|
||||
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
|
||||
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
|
||||
}
|
||||
|
||||
var colored = ctx.getImageData(0, 0, this._width, this._height);
|
||||
this._colorize(colored.data, this._grad);
|
||||
ctx.putImageData(colored, 0, 0);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
_colorize: function (pixels, gradient) {
|
||||
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
|
||||
j = pixels[i] * 4;
|
||||
if (j) {
|
||||
pixels[i - 3] = gradient[j];
|
||||
pixels[i - 2] = gradient[j + 1];
|
||||
pixels[i - 1] = gradient[j + 2];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_createCanvas: function () {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.createElement('canvas');
|
||||
}
|
||||
return { getContext: function () {} };
|
||||
}
|
||||
};
|
||||
|
||||
return simpleheat;
|
||||
}));
|
||||
|
||||
// ---- Leaflet.heat plugin ----
|
||||
(function () {
|
||||
if (typeof L === 'undefined') return;
|
||||
|
||||
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
|
||||
initialize: function (latlngs, options) {
|
||||
this._latlngs = latlngs;
|
||||
L.setOptions(this, options);
|
||||
},
|
||||
|
||||
setLatLngs: function (latlngs) {
|
||||
this._latlngs = latlngs;
|
||||
return this.redraw();
|
||||
},
|
||||
|
||||
addLatLng: function (latlng) {
|
||||
this._latlngs.push(latlng);
|
||||
return this.redraw();
|
||||
},
|
||||
|
||||
setOptions: function (options) {
|
||||
L.setOptions(this, options);
|
||||
if (this._heat) this._updateOptions();
|
||||
return this.redraw();
|
||||
},
|
||||
|
||||
redraw: function () {
|
||||
if (this._heat && !this._frame && this._map && !this._map._animating) {
|
||||
this._frame = L.Util.requestAnimFrame(this._redraw, this);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
onAdd: function (map) {
|
||||
this._map = map;
|
||||
if (!this._canvas) this._initCanvas();
|
||||
if (this.options.pane) this.getPane().appendChild(this._canvas);
|
||||
else map._panes.overlayPane.appendChild(this._canvas);
|
||||
map.on('moveend', this._reset, this);
|
||||
if (map.options.zoomAnimation && L.Browser.any3d) {
|
||||
map.on('zoomanim', this._animateZoom, this);
|
||||
}
|
||||
this._reset();
|
||||
},
|
||||
|
||||
onRemove: function (map) {
|
||||
if (this.options.pane) this.getPane().removeChild(this._canvas);
|
||||
else map.getPanes().overlayPane.removeChild(this._canvas);
|
||||
map.off('moveend', this._reset, this);
|
||||
if (map.options.zoomAnimation) {
|
||||
map.off('zoomanim', this._animateZoom, this);
|
||||
}
|
||||
},
|
||||
|
||||
addTo: function (map) {
|
||||
map.addLayer(this);
|
||||
return this;
|
||||
},
|
||||
|
||||
_initCanvas: function () {
|
||||
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
|
||||
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
|
||||
canvas.style[originProp] = '50% 50%';
|
||||
var size = this._map.getSize();
|
||||
canvas.width = size.x;
|
||||
canvas.height = size.y;
|
||||
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
|
||||
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
|
||||
this._heat = simpleheat(canvas);
|
||||
this._updateOptions();
|
||||
},
|
||||
|
||||
_updateOptions: function () {
|
||||
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
|
||||
if (this.options.gradient) this._heat.gradient(this.options.gradient);
|
||||
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
|
||||
},
|
||||
|
||||
_reset: function () {
|
||||
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
|
||||
L.DomUtil.setPosition(this._canvas, topLeft);
|
||||
var size = this._map.getSize();
|
||||
if (this._heat._width !== size.x) {
|
||||
this._canvas.width = this._heat._width = size.x;
|
||||
}
|
||||
if (this._heat._height !== size.y) {
|
||||
this._canvas.height = this._heat._height = size.y;
|
||||
}
|
||||
this._redraw();
|
||||
},
|
||||
|
||||
_redraw: function () {
|
||||
this._frame = null;
|
||||
if (!this._map) return;
|
||||
var data = [],
|
||||
r = this._heat._r,
|
||||
size = this._map.getSize(),
|
||||
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
|
||||
max = this.options.max === undefined ? 1 : this.options.max,
|
||||
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
|
||||
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
|
||||
cellSize = r / 2,
|
||||
grid = [],
|
||||
panePos = this._map._getMapPanePos(),
|
||||
offsetX = panePos.x % cellSize,
|
||||
offsetY = panePos.y % cellSize,
|
||||
i, len, p, cell, x, y, j, len2, k;
|
||||
|
||||
for (i = 0, len = this._latlngs.length; i < len; i++) {
|
||||
p = this._map.latLngToContainerPoint(this._latlngs[i]);
|
||||
if (bounds.contains(p)) {
|
||||
x = Math.floor((p.x - offsetX) / cellSize) + 2;
|
||||
y = Math.floor((p.y - offsetY) / cellSize) + 2;
|
||||
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
|
||||
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
|
||||
k = alt * v;
|
||||
grid[y] = grid[y] || [];
|
||||
cell = grid[y][x];
|
||||
if (!cell) {
|
||||
grid[y][x] = [p.x, p.y, k];
|
||||
} else {
|
||||
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
|
||||
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
|
||||
cell[2] += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0, len = grid.length; i < len; i++) {
|
||||
if (grid[i]) {
|
||||
for (j = 0, len2 = grid[i].length; j < len2; j++) {
|
||||
cell = grid[i][j];
|
||||
if (cell) {
|
||||
data.push([
|
||||
Math.round(cell[0]),
|
||||
Math.round(cell[1]),
|
||||
Math.min(cell[2], max)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._heat.data(data).draw(this.options.minOpacity);
|
||||
},
|
||||
|
||||
_animateZoom: function (e) {
|
||||
var scale = this._map.getZoomScale(e.zoom),
|
||||
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
|
||||
if (L.DomUtil.setTransform) {
|
||||
L.DomUtil.setTransform(this._canvas, offset, scale);
|
||||
} else {
|
||||
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
L.heatLayer = function (latlngs, options) {
|
||||
return new L.HeatLayer(latlngs, options);
|
||||
};
|
||||
}());
|
||||
16
static/manifest.json
Normal file
16
static/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "INTERCEPT Signal Intelligence",
|
||||
"short_name": "INTERCEPT",
|
||||
"description": "Unified SIGINT platform for software-defined radio analysis",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b1118",
|
||||
"theme_color": "#0b1118",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
85
static/sw.js
Normal file
85
static/sw.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||
const CACHE_NAME = 'intercept-v1';
|
||||
|
||||
const NETWORK_ONLY_PREFIXES = [
|
||||
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||
'/meshtastic/', '/bt_locate/', '/listening/', '/sensor/', '/pager/',
|
||||
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||
'/recordings/', '/controller/', '/fingerprint/', '/ops/',
|
||||
];
|
||||
|
||||
const STATIC_PREFIXES = [
|
||||
'/static/css/',
|
||||
'/static/js/',
|
||||
'/static/icons/',
|
||||
'/static/fonts/',
|
||||
];
|
||||
|
||||
const CACHE_EXACT = ['/manifest.json'];
|
||||
|
||||
function isNetworkOnly(req) {
|
||||
if (req.method !== 'GET') return true;
|
||||
const accept = req.headers.get('Accept') || '';
|
||||
if (accept.includes('text/event-stream')) return true;
|
||||
const url = new URL(req.url);
|
||||
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
function isStaticAsset(req) {
|
||||
const url = new URL(req.url);
|
||||
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const req = e.request;
|
||||
|
||||
// Always bypass service worker for non-GET and streaming routes
|
||||
if (isNetworkOnly(req)) {
|
||||
e.respondWith(fetch(req));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
if (isStaticAsset(req)) {
|
||||
e.respondWith(
|
||||
caches.open(CACHE_NAME).then(cache =>
|
||||
cache.match(req).then(cached => {
|
||||
if (cached) {
|
||||
// Revalidate in background
|
||||
fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
}).catch(() => {});
|
||||
return cached;
|
||||
}
|
||||
return fetch(req).then(res => {
|
||||
if (res && res.status === 200) cache.put(req, res.clone());
|
||||
return res;
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for HTML pages
|
||||
e.respondWith(
|
||||
fetch(req).catch(() =>
|
||||
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user