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:
Smittix
2026-01-20 20:54:07 +00:00
parent 4c71a3bb92
commit 8b42f4ac28
3 changed files with 1359 additions and 5 deletions
+507
View File
@@ -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;
}
+820
View File
@@ -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
View File
@@ -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');
}
}