mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Add signal activity timeline visualization for TSCM mode
New lightweight timeline component that shows RF signal presence over time without heavy waterfall rendering: - Horizontal swimlanes for each frequency/signal source - Bars show transmission duration with height = signal strength - Status colors: blue=new, gray=baseline, orange=burst, red=flagged - Pattern detection for regular interval transmissions - Click to expand and see individual transmission ticks - Right-click to flag signals for investigation - Auto-annotations for new signals, bursts, and patterns - Tooltip with signal details on hover - Time window selector (5m to 2h) - Filter controls (hide baseline, show only new/burst) Integrated into TSCM mode: - Timeline created when TSCM mode is selected - WiFi, Bluetooth, and RF signals feed into timeline - Clears on new sweep start Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Signal Activity Timeline Component
|
||||
* Lightweight visualization for RF signal presence over time
|
||||
* Used for TSCM sweeps and investigative analysis
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
TIMELINE CONTAINER
|
||||
============================================ */
|
||||
.signal-timeline {
|
||||
background: var(--bg-card, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.signal-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signal-timeline-btn {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 9px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.signal-timeline-btn:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.signal-timeline-btn.active {
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
color: #000;
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
/* Time window selector */
|
||||
.signal-timeline-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-window select {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIME AXIS
|
||||
============================================ */
|
||||
.signal-timeline-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 80px 0 100px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SWIMLANES
|
||||
============================================ */
|
||||
.signal-timeline-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-lane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 28px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Signal label */
|
||||
.signal-timeline-label {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-right: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-freq {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.signal-timeline-name {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.signal-timeline-status {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="gone"] {
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TRACK (where bars are drawn)
|
||||
============================================ */
|
||||
.signal-timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signal-timeline-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.signal-timeline-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color, #333);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BARS
|
||||
============================================ */
|
||||
.signal-timeline-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 16px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Strength variants (height) */
|
||||
.signal-timeline-bar[data-strength="1"] { height: 6px; }
|
||||
.signal-timeline-bar[data-strength="2"] { height: 10px; }
|
||||
.signal-timeline-bar[data-strength="3"] { height: 14px; }
|
||||
.signal-timeline-bar[data-strength="4"] { height: 18px; }
|
||||
.signal-timeline-bar[data-strength="5"] { height: 22px; }
|
||||
|
||||
/* Status colors */
|
||||
.signal-timeline-bar[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
animation: flaggedPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flaggedPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover .signal-timeline-bar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED VIEW (tick marks)
|
||||
============================================ */
|
||||
.signal-timeline-ticks {
|
||||
display: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded .signal-timeline-ticks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signal-timeline-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tick[data-strength="1"] { height: 4px; }
|
||||
.signal-timeline-tick[data-strength="2"] { height: 8px; }
|
||||
.signal-timeline-tick[data-strength="3"] { height: 12px; }
|
||||
.signal-timeline-tick[data-strength="4"] { height: 16px; }
|
||||
.signal-timeline-tick[data-strength="5"] { height: 20px; }
|
||||
|
||||
/* ============================================
|
||||
ANNOTATIONS
|
||||
============================================ */
|
||||
.signal-timeline-annotations {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="new"] {
|
||||
border-left: 2px solid var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="burst"] {
|
||||
border-left: 2px solid var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="pattern"] {
|
||||
border-left: 2px solid var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="flagged"] {
|
||||
border-left: 2px solid var(--signal-emergency, #ef4444);
|
||||
color: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIP
|
||||
============================================ */
|
||||
.signal-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #fff);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row span:last-child {
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS ROW
|
||||
============================================ */
|
||||
.signal-timeline-stats {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
border-left: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-stat-count {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-timeline-stat-label {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.signal-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.signal-timeline-empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LEGEND
|
||||
============================================ */
|
||||
.signal-timeline-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
|
||||
.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
|
||||
.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
|
||||
.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
|
||||
|
||||
/* ============================================
|
||||
NOW MARKER
|
||||
============================================ */
|
||||
.signal-timeline-now {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent-green, #22c55e);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.signal-timeline-now::after {
|
||||
content: 'NOW';
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--accent-green, #22c55e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MARKER (first seen indicator)
|
||||
============================================ */
|
||||
.signal-timeline-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 8px solid var(--signal-new, #3b82f6);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.signal-timeline-marker::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--signal-new, #3b82f6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* Signal Activity Timeline Component
|
||||
* Lightweight visualization for RF signal presence over time
|
||||
* Used for TSCM sweeps and investigative analysis
|
||||
*/
|
||||
|
||||
const SignalTimeline = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
timeWindows: {
|
||||
'5m': 5 * 60 * 1000,
|
||||
'15m': 15 * 60 * 1000,
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'2h': 2 * 60 * 60 * 1000
|
||||
},
|
||||
defaultWindow: '30m',
|
||||
maxSignals: 50,
|
||||
burstThreshold: 5, // messages in burst window = burst
|
||||
burstWindow: 60 * 1000, // 1 minute
|
||||
updateInterval: 5000, // refresh every 5 seconds
|
||||
barMinWidth: 2 // minimum bar width in pixels
|
||||
};
|
||||
|
||||
// State
|
||||
const state = {
|
||||
signals: new Map(), // frequency -> signal data
|
||||
annotations: [],
|
||||
filters: {
|
||||
hideBaseline: false,
|
||||
showOnlyNew: false,
|
||||
showOnlyBurst: false
|
||||
},
|
||||
timeWindow: config.defaultWindow,
|
||||
tooltip: null,
|
||||
updateTimer: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Signal data structure
|
||||
*/
|
||||
function createSignal(frequency, name = null) {
|
||||
return {
|
||||
frequency: frequency,
|
||||
name: name || categorizeFrequency(frequency),
|
||||
events: [], // { timestamp, strength, duration }
|
||||
firstSeen: null,
|
||||
lastSeen: null,
|
||||
status: 'new', // new, baseline, burst, flagged, gone
|
||||
pattern: null, // detected pattern description
|
||||
flagged: false,
|
||||
transmissionCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize frequency into human-readable name
|
||||
*/
|
||||
function categorizeFrequency(freq) {
|
||||
const f = parseFloat(freq);
|
||||
if (f >= 2400 && f <= 2500) return 'Wi-Fi 2.4GHz';
|
||||
if (f >= 5150 && f <= 5850) return 'Wi-Fi 5GHz';
|
||||
if (f >= 433 && f <= 434) return '433MHz ISM';
|
||||
if (f >= 868 && f <= 869) return '868MHz ISM';
|
||||
if (f >= 902 && f <= 928) return '915MHz ISM';
|
||||
if (f >= 315 && f <= 316) return '315MHz';
|
||||
if (f >= 2402 && f <= 2480) return 'Bluetooth';
|
||||
if (f >= 144 && f <= 148) return 'VHF Ham';
|
||||
if (f >= 420 && f <= 450) return 'UHF Ham';
|
||||
return `${freq} MHz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a signal event
|
||||
*/
|
||||
function addEvent(frequency, strength = 3, duration = 1000, name = null) {
|
||||
const now = Date.now();
|
||||
let signal = state.signals.get(frequency);
|
||||
|
||||
if (!signal) {
|
||||
signal = createSignal(frequency, name);
|
||||
signal.firstSeen = now;
|
||||
state.signals.set(frequency, signal);
|
||||
|
||||
// Add annotation for new signal
|
||||
addAnnotation('new', `New signal detected: ${signal.name}`, now);
|
||||
}
|
||||
|
||||
// Add event
|
||||
signal.events.push({
|
||||
timestamp: now,
|
||||
strength: Math.min(5, Math.max(1, strength)),
|
||||
duration: duration
|
||||
});
|
||||
|
||||
signal.lastSeen = now;
|
||||
signal.transmissionCount++;
|
||||
|
||||
// Update status
|
||||
updateSignalStatus(signal);
|
||||
|
||||
// Detect patterns
|
||||
detectPatterns(signal);
|
||||
|
||||
// Limit events to prevent memory bloat
|
||||
const windowMs = config.timeWindows['2h'];
|
||||
signal.events = signal.events.filter(e => now - e.timestamp < windowMs);
|
||||
|
||||
return signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signal status based on activity
|
||||
*/
|
||||
function updateSignalStatus(signal) {
|
||||
const now = Date.now();
|
||||
const recentEvents = signal.events.filter(
|
||||
e => now - e.timestamp < config.burstWindow
|
||||
);
|
||||
|
||||
// Check for burst activity
|
||||
if (recentEvents.length >= config.burstThreshold) {
|
||||
if (signal.status !== 'burst') {
|
||||
signal.status = 'burst';
|
||||
addAnnotation('burst',
|
||||
`Burst: ${recentEvents.length} transmissions in ${config.burstWindow/1000}s - ${signal.name}`,
|
||||
now
|
||||
);
|
||||
}
|
||||
} else if (signal.transmissionCount >= 20) {
|
||||
// Baseline if seen many times
|
||||
signal.status = 'baseline';
|
||||
} else if (now - signal.firstSeen < 5 * 60 * 1000) {
|
||||
// New if first seen within 5 minutes
|
||||
signal.status = 'new';
|
||||
}
|
||||
|
||||
// Override if flagged
|
||||
if (signal.flagged) {
|
||||
signal.status = 'flagged';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect repeating patterns in signal events
|
||||
*/
|
||||
function detectPatterns(signal) {
|
||||
if (signal.events.length < 4) return;
|
||||
|
||||
// Get intervals between events
|
||||
const intervals = [];
|
||||
for (let i = 1; i < signal.events.length; i++) {
|
||||
intervals.push(signal.events[i].timestamp - signal.events[i-1].timestamp);
|
||||
}
|
||||
|
||||
// Look for consistent interval (within 10% tolerance)
|
||||
if (intervals.length >= 3) {
|
||||
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const tolerance = avgInterval * 0.1;
|
||||
const consistent = intervals.filter(
|
||||
i => Math.abs(i - avgInterval) <= tolerance
|
||||
).length;
|
||||
|
||||
if (consistent >= intervals.length * 0.7) {
|
||||
const seconds = Math.round(avgInterval / 1000);
|
||||
if (seconds >= 1 && seconds <= 3600) {
|
||||
const patternStr = seconds < 60
|
||||
? `${seconds}s interval`
|
||||
: `${Math.round(seconds/60)}m interval`;
|
||||
|
||||
if (signal.pattern !== patternStr) {
|
||||
signal.pattern = patternStr;
|
||||
addAnnotation('pattern',
|
||||
`Pattern detected: ${patternStr} - ${signal.name}`,
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add annotation
|
||||
*/
|
||||
function addAnnotation(type, message, timestamp) {
|
||||
state.annotations.unshift({
|
||||
type: type,
|
||||
message: message,
|
||||
timestamp: timestamp
|
||||
});
|
||||
|
||||
// Limit annotations
|
||||
if (state.annotations.length > 20) {
|
||||
state.annotations.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a signal for investigation
|
||||
*/
|
||||
function flagSignal(frequency) {
|
||||
const signal = state.signals.get(frequency);
|
||||
if (signal) {
|
||||
signal.flagged = !signal.flagged;
|
||||
signal.status = signal.flagged ? 'flagged' : 'new';
|
||||
addAnnotation('flagged',
|
||||
signal.flagged
|
||||
? `Flagged for investigation: ${signal.name}`
|
||||
: `Unflagged: ${signal.name}`,
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark signal as gone (no longer transmitting)
|
||||
*/
|
||||
function markGone(frequency) {
|
||||
const signal = state.signals.get(frequency);
|
||||
if (signal && signal.status !== 'gone') {
|
||||
signal.status = 'gone';
|
||||
addAnnotation('gone', `Signal disappeared: ${signal.name}`, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the timeline DOM element
|
||||
*/
|
||||
function createTimeline(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
|
||||
const timeline = document.createElement('div');
|
||||
timeline.className = 'signal-timeline';
|
||||
timeline.id = 'signalTimeline';
|
||||
|
||||
timeline.innerHTML = `
|
||||
<div class="signal-timeline-header">
|
||||
<span class="signal-timeline-title">Signal Activity Timeline</span>
|
||||
<div class="signal-timeline-controls">
|
||||
<button class="signal-timeline-btn" data-filter="hideBaseline" title="Hide baseline signals">
|
||||
Hide Known
|
||||
</button>
|
||||
<button class="signal-timeline-btn" data-filter="showOnlyNew" title="Show only new signals">
|
||||
New Only
|
||||
</button>
|
||||
<button class="signal-timeline-btn" data-filter="showOnlyBurst" title="Show only burst activity">
|
||||
Bursts
|
||||
</button>
|
||||
<div class="signal-timeline-window">
|
||||
<span>Window:</span>
|
||||
<select id="timelineWindowSelect">
|
||||
<option value="5m">5 min</option>
|
||||
<option value="15m">15 min</option>
|
||||
<option value="30m" selected>30 min</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-axis" id="timelineAxis"></div>
|
||||
<div class="signal-timeline-lanes" id="timelineLanes">
|
||||
<div class="signal-timeline-empty">
|
||||
<div class="signal-timeline-empty-icon">📡</div>
|
||||
<div>No signals recorded yet</div>
|
||||
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
|
||||
<div class="signal-timeline-legend">
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot new"></div>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot baseline"></div>
|
||||
<span>Baseline</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot burst"></div>
|
||||
<span>Burst</span>
|
||||
</div>
|
||||
<div class="signal-timeline-legend-item">
|
||||
<div class="signal-timeline-legend-dot flagged"></div>
|
||||
<span>Flagged</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(timeline);
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners(timeline);
|
||||
|
||||
// Create tooltip element
|
||||
createTooltip();
|
||||
|
||||
// Start update timer
|
||||
startUpdateTimer();
|
||||
|
||||
// Initial render
|
||||
render();
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners
|
||||
*/
|
||||
function setupEventListeners(timeline) {
|
||||
// Filter buttons
|
||||
timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const filter = btn.dataset.filter;
|
||||
state.filters[filter] = !state.filters[filter];
|
||||
btn.classList.toggle('active', state.filters[filter]);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// Time window selector
|
||||
const windowSelect = timeline.querySelector('#timelineWindowSelect');
|
||||
if (windowSelect) {
|
||||
windowSelect.addEventListener('change', (e) => {
|
||||
state.timeWindow = e.target.value;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// Lane click to expand
|
||||
timeline.addEventListener('click', (e) => {
|
||||
const lane = e.target.closest('.signal-timeline-lane');
|
||||
if (lane && !e.target.closest('button')) {
|
||||
lane.classList.toggle('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// Lane right-click to flag
|
||||
timeline.addEventListener('contextmenu', (e) => {
|
||||
const lane = e.target.closest('.signal-timeline-lane');
|
||||
if (lane) {
|
||||
e.preventDefault();
|
||||
const freq = lane.dataset.frequency;
|
||||
flagSignal(freq);
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip element
|
||||
*/
|
||||
function createTooltip() {
|
||||
if (state.tooltip) return;
|
||||
|
||||
state.tooltip = document.createElement('div');
|
||||
state.tooltip.className = 'signal-timeline-tooltip';
|
||||
state.tooltip.style.display = 'none';
|
||||
document.body.appendChild(state.tooltip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tooltip
|
||||
*/
|
||||
function showTooltip(e, signal) {
|
||||
if (!state.tooltip) return;
|
||||
|
||||
const now = Date.now();
|
||||
const duration = signal.lastSeen - signal.firstSeen;
|
||||
const durationStr = formatDuration(duration);
|
||||
const lastSeenStr = formatTimeAgo(signal.lastSeen);
|
||||
|
||||
state.tooltip.innerHTML = `
|
||||
<div class="signal-timeline-tooltip-header">${signal.name}</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Frequency:</span>
|
||||
<span>${signal.frequency} MHz</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>First seen:</span>
|
||||
<span>${formatTime(signal.firstSeen)}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Last seen:</span>
|
||||
<span>${lastSeenStr}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Transmissions:</span>
|
||||
<span>${signal.transmissionCount}</span>
|
||||
</div>
|
||||
${signal.pattern ? `
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Pattern:</span>
|
||||
<span>${signal.pattern}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="signal-timeline-tooltip-row">
|
||||
<span>Status:</span>
|
||||
<span style="text-transform: capitalize;">${signal.status}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
state.tooltip.style.display = 'block';
|
||||
state.tooltip.style.left = (e.clientX + 10) + 'px';
|
||||
state.tooltip.style.top = (e.clientY + 10) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide tooltip
|
||||
*/
|
||||
function hideTooltip() {
|
||||
if (state.tooltip) {
|
||||
state.tooltip.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update timer
|
||||
*/
|
||||
function startUpdateTimer() {
|
||||
if (state.updateTimer) {
|
||||
clearInterval(state.updateTimer);
|
||||
}
|
||||
state.updateTimer = setInterval(() => {
|
||||
render();
|
||||
}, config.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the update timer
|
||||
*/
|
||||
function stopUpdateTimer() {
|
||||
if (state.updateTimer) {
|
||||
clearInterval(state.updateTimer);
|
||||
state.updateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timeline
|
||||
*/
|
||||
function render() {
|
||||
const lanesContainer = document.getElementById('timelineLanes');
|
||||
const axisContainer = document.getElementById('timelineAxis');
|
||||
const annotationsContainer = document.getElementById('timelineAnnotations');
|
||||
|
||||
if (!lanesContainer) return;
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = config.timeWindows[state.timeWindow];
|
||||
const startTime = now - windowMs;
|
||||
|
||||
// Render time axis
|
||||
renderAxis(axisContainer, startTime, now, windowMs);
|
||||
|
||||
// Get filtered signals
|
||||
let signals = Array.from(state.signals.values());
|
||||
|
||||
// Apply filters
|
||||
if (state.filters.hideBaseline) {
|
||||
signals = signals.filter(s => s.status !== 'baseline');
|
||||
}
|
||||
if (state.filters.showOnlyNew) {
|
||||
signals = signals.filter(s => s.status === 'new');
|
||||
}
|
||||
if (state.filters.showOnlyBurst) {
|
||||
signals = signals.filter(s => s.status === 'burst');
|
||||
}
|
||||
|
||||
// Sort by last seen (most recent first), then by status priority
|
||||
const statusPriority = { flagged: 0, burst: 1, new: 2, baseline: 3, gone: 4 };
|
||||
signals.sort((a, b) => {
|
||||
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return b.lastSeen - a.lastSeen;
|
||||
});
|
||||
|
||||
// Render lanes
|
||||
if (signals.length === 0) {
|
||||
lanesContainer.innerHTML = `
|
||||
<div class="signal-timeline-empty">
|
||||
<div class="signal-timeline-empty-icon">📡</div>
|
||||
<div>No signals recorded yet</div>
|
||||
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
lanesContainer.innerHTML = signals.map(signal =>
|
||||
renderLane(signal, startTime, now, windowMs)
|
||||
).join('');
|
||||
|
||||
// Add event listeners to new lanes
|
||||
lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => {
|
||||
const freq = lane.dataset.frequency;
|
||||
const signal = state.signals.get(freq);
|
||||
|
||||
lane.addEventListener('mouseenter', (e) => showTooltip(e, signal));
|
||||
lane.addEventListener('mousemove', (e) => showTooltip(e, signal));
|
||||
lane.addEventListener('mouseleave', hideTooltip);
|
||||
});
|
||||
}
|
||||
|
||||
// Render annotations
|
||||
renderAnnotations(annotationsContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render time axis
|
||||
*/
|
||||
function renderAxis(container, startTime, endTime, windowMs) {
|
||||
if (!container) return;
|
||||
|
||||
const labels = [];
|
||||
const steps = 6;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const time = startTime + (windowMs * i / steps);
|
||||
const label = i === steps ? 'Now' : formatTimeShort(time);
|
||||
labels.push(`<span class="signal-timeline-axis-label">${label}</span>`);
|
||||
}
|
||||
|
||||
container.innerHTML = labels.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single lane
|
||||
*/
|
||||
function renderLane(signal, startTime, endTime, windowMs) {
|
||||
const isBaseline = signal.status === 'baseline';
|
||||
|
||||
// Get events within time window
|
||||
const visibleEvents = signal.events.filter(
|
||||
e => e.timestamp >= startTime && e.timestamp <= endTime
|
||||
);
|
||||
|
||||
// Generate bars HTML
|
||||
const barsHtml = aggregateAndRenderBars(visibleEvents, startTime, windowMs);
|
||||
|
||||
// Generate ticks for expanded view
|
||||
const ticksHtml = visibleEvents.map(event => {
|
||||
const position = ((event.timestamp - startTime) / windowMs) * 100;
|
||||
return `<div class="signal-timeline-tick"
|
||||
style="left: ${position}%;"
|
||||
data-strength="${event.strength}"></div>`;
|
||||
}).join('');
|
||||
|
||||
// Stats
|
||||
const recentCount = visibleEvents.length;
|
||||
|
||||
return `
|
||||
<div class="signal-timeline-lane ${isBaseline ? 'baseline' : ''}"
|
||||
data-frequency="${signal.frequency}"
|
||||
data-status="${signal.status}">
|
||||
<div class="signal-timeline-status" data-status="${signal.status}"></div>
|
||||
<div class="signal-timeline-label">
|
||||
<span class="signal-timeline-freq">${signal.frequency}</span>
|
||||
<span class="signal-timeline-name">${signal.name}</span>
|
||||
</div>
|
||||
<div class="signal-timeline-track">
|
||||
<div class="signal-timeline-track-bg">
|
||||
${barsHtml}
|
||||
</div>
|
||||
<div class="signal-timeline-ticks">
|
||||
${ticksHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-timeline-stats">
|
||||
<span class="signal-timeline-stat-count">${recentCount}</span>
|
||||
<span class="signal-timeline-stat-label">events</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate events into bars and render
|
||||
*/
|
||||
function aggregateAndRenderBars(events, startTime, windowMs) {
|
||||
if (events.length === 0) return '';
|
||||
|
||||
// Group nearby events into bars
|
||||
const bars = [];
|
||||
let currentBar = null;
|
||||
const minGap = windowMs / 100; // Merge events within 1% of window
|
||||
|
||||
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const event of events) {
|
||||
if (!currentBar) {
|
||||
currentBar = {
|
||||
start: event.timestamp,
|
||||
end: event.timestamp + event.duration,
|
||||
maxStrength: event.strength,
|
||||
count: 1
|
||||
};
|
||||
} else if (event.timestamp - currentBar.end <= minGap) {
|
||||
// Extend current bar
|
||||
currentBar.end = Math.max(currentBar.end, event.timestamp + event.duration);
|
||||
currentBar.maxStrength = Math.max(currentBar.maxStrength, event.strength);
|
||||
currentBar.count++;
|
||||
} else {
|
||||
// Start new bar
|
||||
bars.push(currentBar);
|
||||
currentBar = {
|
||||
start: event.timestamp,
|
||||
end: event.timestamp + event.duration,
|
||||
maxStrength: event.strength,
|
||||
count: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
if (currentBar) bars.push(currentBar);
|
||||
|
||||
// Determine status for bars based on count
|
||||
return bars.map(bar => {
|
||||
const left = ((bar.start - startTime) / windowMs) * 100;
|
||||
const width = Math.max(
|
||||
config.barMinWidth / 8, // Convert px to approximate %
|
||||
((bar.end - bar.start) / windowMs) * 100
|
||||
);
|
||||
const status = bar.count >= config.burstThreshold ? 'burst' :
|
||||
bar.count > 1 ? 'repeated' : 'new';
|
||||
|
||||
return `<div class="signal-timeline-bar"
|
||||
style="left: ${left}%; width: ${width}%;"
|
||||
data-strength="${bar.maxStrength}"
|
||||
data-status="${status}"></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render annotations
|
||||
*/
|
||||
function renderAnnotations(container) {
|
||||
if (!container) return;
|
||||
|
||||
const recentAnnotations = state.annotations.slice(0, 5);
|
||||
|
||||
if (recentAnnotations.length === 0) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = recentAnnotations.map(ann => {
|
||||
const icons = {
|
||||
new: '🆕',
|
||||
burst: '⚡',
|
||||
pattern: '🔄',
|
||||
flagged: '🚩',
|
||||
gone: '📴'
|
||||
};
|
||||
return `
|
||||
<div class="signal-timeline-annotation" data-type="${ann.type}">
|
||||
<span class="signal-timeline-annotation-icon">${icons[ann.type] || '📡'}</span>
|
||||
<span>${ann.message}</span>
|
||||
<span style="margin-left: auto; opacity: 0.6;">${formatTimeAgo(ann.timestamp)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
function formatTime(timestamp) {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format short time for axis
|
||||
*/
|
||||
function formatTimeShort(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time ago
|
||||
*/
|
||||
function formatTimeAgo(timestamp) {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
function clear() {
|
||||
state.signals.clear();
|
||||
state.annotations = [];
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export data for reports
|
||||
*/
|
||||
function exportData() {
|
||||
const signals = Array.from(state.signals.values()).map(s => ({
|
||||
frequency: s.frequency,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
pattern: s.pattern,
|
||||
firstSeen: new Date(s.firstSeen).toISOString(),
|
||||
lastSeen: new Date(s.lastSeen).toISOString(),
|
||||
transmissionCount: s.transmissionCount,
|
||||
flagged: s.flagged
|
||||
}));
|
||||
|
||||
return {
|
||||
exportTime: new Date().toISOString(),
|
||||
timeWindow: state.timeWindow,
|
||||
signals: signals,
|
||||
annotations: state.annotations.map(a => ({
|
||||
...a,
|
||||
timestamp: new Date(a.timestamp).toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary stats
|
||||
*/
|
||||
function getStats() {
|
||||
const signals = Array.from(state.signals.values());
|
||||
return {
|
||||
total: signals.length,
|
||||
new: signals.filter(s => s.status === 'new').length,
|
||||
baseline: signals.filter(s => s.status === 'baseline').length,
|
||||
burst: signals.filter(s => s.status === 'burst').length,
|
||||
flagged: signals.filter(s => s.flagged).length,
|
||||
withPattern: signals.filter(s => s.pattern).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the timeline
|
||||
*/
|
||||
function destroy() {
|
||||
stopUpdateTimer();
|
||||
if (state.tooltip) {
|
||||
state.tooltip.remove();
|
||||
state.tooltip = null;
|
||||
}
|
||||
const timeline = document.getElementById('signalTimeline');
|
||||
if (timeline) {
|
||||
timeline.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// Initialization
|
||||
create: createTimeline,
|
||||
destroy: destroy,
|
||||
|
||||
// Data management
|
||||
addEvent: addEvent,
|
||||
flagSignal: flagSignal,
|
||||
markGone: markGone,
|
||||
clear: clear,
|
||||
|
||||
// Rendering
|
||||
render: render,
|
||||
|
||||
// Data access
|
||||
getSignals: () => Array.from(state.signals.values()),
|
||||
getAnnotations: () => state.annotations,
|
||||
getStats: getStats,
|
||||
exportData: exportData,
|
||||
|
||||
// Configuration
|
||||
setTimeWindow: (window) => {
|
||||
if (config.timeWindows[window]) {
|
||||
state.timeWindow = window;
|
||||
render();
|
||||
}
|
||||
},
|
||||
|
||||
// Filter controls
|
||||
setFilter: (filter, value) => {
|
||||
if (state.filters.hasOwnProperty(filter)) {
|
||||
state.filters[filter] = value;
|
||||
render();
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.SignalTimeline = SignalTimeline;
|
||||
+32
-5
@@ -17,6 +17,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -1456,6 +1457,9 @@
|
||||
<!-- Sweep Summary (shown after sweep completes) -->
|
||||
<div id="tscmSweepSummary" style="display: none; margin-bottom: 16px;"></div>
|
||||
|
||||
<!-- Signal Activity Timeline -->
|
||||
<div id="tscmTimelineContainer" style="margin-bottom: 16px;"></div>
|
||||
|
||||
<!-- Cross-Protocol Correlations (shown when correlations found) -->
|
||||
<div id="tscmCorrelationsContainer" style="display: none;"></div>
|
||||
|
||||
@@ -1588,6 +1592,7 @@
|
||||
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
|
||||
<script>
|
||||
@@ -2057,6 +2062,10 @@
|
||||
if (mode === 'tscm') {
|
||||
loadTscmBaselines();
|
||||
refreshTscmDevices();
|
||||
// Initialize signal timeline if not already created
|
||||
if (!document.getElementById('signalTimeline')) {
|
||||
SignalTimeline.create('tscmTimelineContainer');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
@@ -7782,7 +7791,7 @@
|
||||
];
|
||||
|
||||
function renderSatelliteList() {
|
||||
const list = document.getElementById('satelliteList');
|
||||
const list = document.getElementById('satTrackingList');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = trackedSatellites.map((sat, idx) => `
|
||||
@@ -7894,14 +7903,15 @@
|
||||
if (data.status === 'success' && data.satellites) {
|
||||
let added = 0;
|
||||
data.satellites.forEach(sat => {
|
||||
if (!trackedSatellites.find(s => s.norad === sat.norad)) {
|
||||
const noradStr = String(sat.norad);
|
||||
if (!trackedSatellites.find(s => s.norad === noradStr)) {
|
||||
trackedSatellites.push({
|
||||
id: sat.id,
|
||||
id: sat.name.replace(/[^a-zA-Z0-9-]/g, '-'),
|
||||
name: sat.name,
|
||||
norad: sat.norad,
|
||||
norad: noradStr,
|
||||
builtin: false,
|
||||
checked: false, // Don't auto-select
|
||||
tle: sat.tle
|
||||
tle: [sat.name, sat.tle1, sat.tle2]
|
||||
});
|
||||
added++;
|
||||
}
|
||||
@@ -8171,6 +8181,9 @@
|
||||
document.getElementById('startTscmBtn').style.display = 'none';
|
||||
document.getElementById('stopTscmBtn').style.display = 'block';
|
||||
document.getElementById('tscmProgress').style.display = 'flex';
|
||||
|
||||
// Clear and reset the signal timeline for new sweep
|
||||
SignalTimeline.clear();
|
||||
document.getElementById('tscmReportBtn').style.display = 'none';
|
||||
|
||||
// Show warnings if any devices unavailable
|
||||
@@ -8864,6 +8877,10 @@
|
||||
body: JSON.stringify(device)
|
||||
}).catch(e => console.error('Baseline feed error:', e));
|
||||
}
|
||||
// Add to signal timeline
|
||||
const freq = device.channel <= 14 ? '2400' : '5000';
|
||||
const strength = Math.min(5, Math.max(1, Math.ceil((device.signal + 100) / 20)));
|
||||
SignalTimeline.addEvent(freq, strength, 2000, device.ssid || 'Hidden WiFi');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8886,6 +8903,9 @@
|
||||
body: JSON.stringify(device)
|
||||
}).catch(e => console.error('Baseline feed error:', e));
|
||||
}
|
||||
// Add to signal timeline
|
||||
const strength = device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3;
|
||||
SignalTimeline.addEvent('2450', strength, 1500, device.name || 'Bluetooth Device');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8913,6 +8933,13 @@
|
||||
body: JSON.stringify(signal)
|
||||
}).catch(e => console.error('Baseline feed error:', e));
|
||||
}
|
||||
// Add to signal timeline
|
||||
const strength = signal.power_dbm ? Math.min(5, Math.max(1, Math.ceil((signal.power_dbm + 60) / 15))) : 3;
|
||||
SignalTimeline.addEvent(String(signal.frequency), strength, 1000, signal.classification || 'RF Signal');
|
||||
} else {
|
||||
// Update existing signal on timeline (show recurring transmission)
|
||||
const strength = signal.power_dbm ? Math.min(5, Math.max(1, Math.ceil((signal.power_dbm + 60) / 15))) : 3;
|
||||
SignalTimeline.addEvent(String(signal.frequency), strength, 500, signal.classification || 'RF Signal');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user