mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
Replace emojis throughout the codebase with inline SVG icons using the Icons utility. Remove decorative icons where text labels already describe the content. Add classification dot CSS for risk indicators. - Extend Icons utility with comprehensive SVG icon set - Update navigation, header stats, and action buttons - Update playback controls and volume icons - Remove decorative device type and panel header emojis - Clean up notifications and alert messages - Add CSS for classification status dots Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
906 lines
32 KiB
JavaScript
906 lines
32 KiB
JavaScript
/**
|
|
* 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 '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);
|
|
|
|
// 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',
|
|
`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, 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 = `
|
|
<div class="signal-timeline-header" id="timelineHeader">
|
|
<div style="display: flex; align-items: center;">
|
|
<span class="signal-timeline-collapse-icon">▼</span>
|
|
<span class="signal-timeline-title">Signal Activity Timeline</span>
|
|
</div>
|
|
<div class="signal-timeline-header-stats" id="timelineHeaderStats">
|
|
<div class="signal-timeline-header-stat">
|
|
<span class="stat-value" id="timelineStatTotal">0</span>
|
|
<span>signals</span>
|
|
</div>
|
|
<div class="signal-timeline-header-stat">
|
|
<span class="stat-value" id="timelineStatNew">0</span>
|
|
<span>new</span>
|
|
</div>
|
|
<div class="signal-timeline-header-stat">
|
|
<span class="stat-value" id="timelineStatBurst">0</span>
|
|
<span>burst</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="signal-timeline-body">
|
|
<div class="signal-timeline-controls" style="display: flex; align-items: center; gap: 6px; padding: 8px 0; flex-wrap: wrap;">
|
|
<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" style="margin-left: auto;">
|
|
<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 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>
|
|
</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) {
|
|
// 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 = `
|
|
<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 (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 = `
|
|
<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 {
|
|
let html = displayedSignals.map(signal =>
|
|
renderLane(signal, startTime, now, windowMs)
|
|
).join('');
|
|
|
|
// Show indicator if there are more signals
|
|
if (hiddenCount > 0) {
|
|
html += `
|
|
<div class="signal-timeline-more" style="text-align: center; padding: 8px; font-size: 10px; color: var(--text-dim, #666);">
|
|
+${hiddenCount} more signals (scroll or adjust filters)
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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(`<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 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 `
|
|
<div class="signal-timeline-annotation" data-type="${ann.type}">
|
|
<span class="signal-timeline-annotation-icon">${iconHtml}</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;
|