/** * 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: 100, // max signals to track in memory maxDisplayedLanes: 15, // max lanes to show at once (scroll for more) 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 '2.4 GHz wireless band'; if (f >= 5150 && f <= 5850) return '5 GHz wireless band'; if (f >= 433 && f <= 434) return '433 MHz low-power band'; if (f >= 868 && f <= 869) return '868 MHz low-power band'; if (f >= 902 && f <= 928) return '915 MHz low-power band'; if (f >= 315 && f <= 316) return '315MHz'; if (f >= 2402 && f <= 2480) return 'Bluetooth band'; if (f >= 144 && f <= 148) return 'VHF amateur band'; if (f >= 420 && f <= 450) return 'UHF amateur band'; 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 observed: ${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); // Prune old signals if we exceed max if (state.signals.size > config.maxSignals) { pruneOldSignals(); } return signal; } /** * Remove oldest/least active signals to stay under limit */ function pruneOldSignals() { const signals = Array.from(state.signals.entries()); // Sort by last seen (oldest first), but keep flagged signals signals.sort((a, b) => { if (a[1].flagged && !b[1].flagged) return 1; if (!a[1].flagged && b[1].flagged) return -1; return a[1].lastSeen - b[1].lastSeen; }); // Remove oldest signals until under limit const toRemove = signals.length - config.maxSignals; for (let i = 0; i < toRemove; i++) { if (!signals[i][1].flagged) { state.signals.delete(signals[i][0]); } } } /** * 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', `Activity cluster: ${recentEvents.length} events 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', `Repeating pattern observed: ${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 ? `Marked for review: ${signal.name}` : `Review mark removed: ${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 no longer observed: ${signal.name}`, Date.now()); } } /** * Create the timeline DOM element */ function createTimeline(containerId, options = {}) { const container = document.getElementById(containerId); if (!container) return null; const startCollapsed = options.collapsed !== false; const timeline = document.createElement('div'); timeline.className = 'signal-timeline' + (startCollapsed ? ' collapsed' : ''); timeline.id = 'signalTimeline'; timeline.innerHTML = `
Signal Activity Timeline
0 signals
0 new
0 burst
Window:
📡
No signal activity recorded
Activity will appear here as signals are observed
New
Baseline
Burst
Flagged
`; 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) { // Collapse toggle const header = timeline.querySelector('#timelineHeader'); if (header) { header.addEventListener('click', (e) => { // Don't toggle if clicking on controls inside header if (e.target.closest('button') || e.target.closest('select')) return; timeline.classList.toggle('collapsed'); }); } // Filter buttons timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); // Prevent collapse toggle 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('click', (e) => e.stopPropagation()); 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 = `
${signal.name}
Frequency: ${signal.frequency} MHz
First seen: ${formatTime(signal.firstSeen)}
Last seen: ${lastSeenStr}
Transmissions: ${signal.transmissionCount}
${signal.pattern ? `
Pattern: ${signal.pattern}
` : ''}
Status: ${signal.status}
`; 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 (limit displayed for performance) const totalSignals = signals.length; const displayedSignals = signals.slice(0, config.maxDisplayedLanes); const hiddenCount = totalSignals - displayedSignals.length; if (signals.length === 0) { lanesContainer.innerHTML = `
📡
No signal activity recorded
Activity will appear here as signals are observed
`; } else { let html = displayedSignals.map(signal => renderLane(signal, startTime, now, windowMs) ).join(''); // Show indicator if there are more signals if (hiddenCount > 0) { html += `
+${hiddenCount} more signals (scroll or adjust filters)
`; } lanesContainer.innerHTML = html; // 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); }); } // Update header stats const allSignals = Array.from(state.signals.values()); const statTotal = document.getElementById('timelineStatTotal'); const statNew = document.getElementById('timelineStatNew'); const statBurst = document.getElementById('timelineStatBurst'); if (statTotal) statTotal.textContent = allSignals.length; if (statNew) statNew.textContent = allSignals.filter(s => s.status === 'new').length; if (statBurst) statBurst.textContent = allSignals.filter(s => s.status === 'burst').length; // 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(`${label}`); } 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 `
`; }).join(''); // Stats const recentCount = visibleEvents.length; return `
${signal.frequency} ${signal.name}
${barsHtml}
${ticksHtml}
${recentCount} events
`; } /** * 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 `
`; }).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 iconFuncs = { new: () => Icons.newBadge('icon--sm'), burst: () => Icons.meter('icon--sm'), pattern: () => Icons.refresh('icon--sm'), flagged: () => Icons.flag('icon--sm'), gone: () => Icons.offline('icon--sm') }; const iconHtml = iconFuncs[ann.type] ? iconFuncs[ann.type]() : Icons.sensor('icon--sm'); return `
${iconHtml} ${ann.message} ${formatTimeAgo(ann.timestamp)}
`; }).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;