diff --git a/static/css/components/signal-timeline.css b/static/css/components/signal-timeline.css new file mode 100644 index 0000000..60ff7c0 --- /dev/null +++ b/static/css/components/signal-timeline.css @@ -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; +} diff --git a/static/js/components/signal-timeline.js b/static/js/components/signal-timeline.js new file mode 100644 index 0000000..2f779ec --- /dev/null +++ b/static/js/components/signal-timeline.js @@ -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 = ` +