diff --git a/static/css/components/activity-timeline.css b/static/css/components/activity-timeline.css
new file mode 100644
index 0000000..fa1bf4c
--- /dev/null
+++ b/static/css/components/activity-timeline.css
@@ -0,0 +1,696 @@
+/**
+ * Activity Timeline Component
+ * Reusable, configuration-driven timeline visualization
+ * Supports visual modes: compact, enriched, summary
+ */
+
+/* ============================================
+ CSS VARIABLES (with fallbacks)
+ ============================================ */
+.activity-timeline {
+ --timeline-bg: var(--bg-card, #1a1a1a);
+ --timeline-border: var(--border-color, #333);
+ --timeline-bg-secondary: var(--bg-secondary, #252525);
+ --timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
+ --timeline-text-primary: var(--text-primary, #fff);
+ --timeline-text-secondary: var(--text-secondary, #888);
+ --timeline-text-dim: var(--text-dim, #666);
+ --timeline-accent: var(--accent-cyan, #4a9eff);
+ --timeline-status-new: var(--signal-new, #3b82f6);
+ --timeline-status-baseline: var(--signal-baseline, #6b7280);
+ --timeline-status-burst: var(--signal-burst, #f59e0b);
+ --timeline-status-flagged: var(--signal-emergency, #ef4444);
+ --timeline-status-gone: var(--text-dim, #666);
+}
+
+/* ============================================
+ TIMELINE CONTAINER
+ ============================================ */
+.activity-timeline {
+ background: var(--timeline-bg);
+ border: 1px solid var(--timeline-border);
+ border-radius: 6px;
+ font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
+ font-size: 11px;
+}
+
+.activity-timeline.collapsed .activity-timeline-body {
+ display: none;
+}
+
+.activity-timeline.collapsed .activity-timeline-header {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 10px;
+}
+
+.activity-timeline.collapsed .activity-timeline-collapse-icon {
+ transform: rotate(-90deg);
+}
+
+/* ============================================
+ HEADER
+ ============================================ */
+.activity-timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s ease;
+}
+
+.activity-timeline-header:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.activity-timeline-collapse-icon {
+ margin-right: 8px;
+ font-size: 10px;
+ transition: transform 0.2s ease;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--timeline-text-secondary);
+}
+
+.activity-timeline-header-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 10px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-header-stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.activity-timeline-header-stat .stat-value {
+ color: var(--timeline-text-primary);
+ font-weight: 500;
+}
+
+/* ============================================
+ BODY
+ ============================================ */
+.activity-timeline-body {
+ padding: 0 12px 12px 12px;
+ border-top: 1px solid var(--timeline-border);
+}
+
+/* ============================================
+ CONTROLS
+ ============================================ */
+.activity-timeline-controls {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ padding: 8px 0;
+ flex-wrap: wrap;
+}
+
+.activity-timeline-btn {
+ background: var(--timeline-bg-secondary);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text-secondary);
+ font-size: 9px;
+ padding: 4px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-family: inherit;
+}
+
+.activity-timeline-btn:hover {
+ background: var(--timeline-bg-elevated);
+ color: var(--timeline-text-primary);
+}
+
+.activity-timeline-btn.active {
+ background: var(--timeline-accent);
+ color: #000;
+ border-color: var(--timeline-accent);
+}
+
+.activity-timeline-window {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+ margin-left: auto;
+}
+
+.activity-timeline-window-select {
+ background: var(--timeline-bg-secondary);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text-primary);
+ font-size: 9px;
+ padding: 3px 6px;
+ border-radius: 3px;
+ font-family: inherit;
+}
+
+/* ============================================
+ TIME AXIS
+ ============================================ */
+.activity-timeline-axis {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 50px 0 140px;
+ margin-bottom: 6px;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-axis-label {
+ position: relative;
+}
+
+.activity-timeline-axis-label::before {
+ content: '';
+ position: absolute;
+ top: -4px;
+ left: 50%;
+ width: 1px;
+ height: 4px;
+ background: var(--timeline-border);
+}
+
+/* ============================================
+ LANES CONTAINER
+ ============================================ */
+.activity-timeline-lanes {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ max-height: 180px;
+ overflow-y: auto;
+ margin-top: 6px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar {
+ width: 6px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-track {
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-thumb {
+ background: var(--timeline-border);
+ border-radius: 3px;
+}
+
+.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
+ background: var(--timeline-text-dim);
+}
+
+/* ============================================
+ INDIVIDUAL LANE
+ ============================================ */
+.activity-timeline-lane {
+ display: flex;
+ align-items: stretch;
+ min-height: 32px;
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.activity-timeline-lane:hover {
+ background: var(--timeline-bg-elevated);
+}
+
+.activity-timeline-lane.expanded {
+ min-height: auto;
+}
+
+.activity-timeline-lane.baseline {
+ opacity: 0.5;
+}
+
+.activity-timeline-lane.baseline:hover {
+ opacity: 0.8;
+}
+
+/* Status indicator strip */
+.activity-timeline-status {
+ width: 4px;
+ min-width: 4px;
+ flex-shrink: 0;
+}
+
+.activity-timeline-status[data-status="new"] {
+ background: var(--timeline-status-new);
+}
+
+.activity-timeline-status[data-status="baseline"] {
+ background: var(--timeline-status-baseline);
+}
+
+.activity-timeline-status[data-status="burst"] {
+ background: var(--timeline-status-burst);
+}
+
+.activity-timeline-status[data-status="flagged"] {
+ background: var(--timeline-status-flagged);
+}
+
+.activity-timeline-status[data-status="gone"] {
+ background: var(--timeline-status-gone);
+}
+
+/* Label section */
+.activity-timeline-label {
+ width: 130px;
+ min-width: 130px;
+ padding: 6px 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1px;
+ border-right: 1px solid var(--timeline-border);
+ overflow: hidden;
+}
+
+.activity-timeline-id {
+ color: var(--timeline-text-primary);
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+.activity-timeline-name {
+ color: var(--timeline-text-dim);
+ font-size: 9px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+/* ============================================
+ TRACK (where bars are drawn)
+ ============================================ */
+.activity-timeline-track {
+ flex: 1;
+ position: relative;
+ height: 100%;
+ min-height: 32px;
+ padding: 4px 8px;
+}
+
+.activity-timeline-track-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+}
+
+/* ============================================
+ SIGNAL BARS
+ ============================================ */
+.activity-timeline-bar {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 14px;
+ min-width: 2px;
+ border-radius: 2px;
+ transition: opacity 0.15s ease;
+}
+
+/* Strength variants */
+.activity-timeline-bar[data-strength="1"] { height: 5px; }
+.activity-timeline-bar[data-strength="2"] { height: 9px; }
+.activity-timeline-bar[data-strength="3"] { height: 13px; }
+.activity-timeline-bar[data-strength="4"] { height: 17px; }
+.activity-timeline-bar[data-strength="5"] { height: 21px; }
+
+/* Status colors */
+.activity-timeline-bar[data-status="new"],
+.activity-timeline-bar[data-status="repeated"] {
+ background: var(--timeline-status-new);
+ box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
+}
+
+.activity-timeline-bar[data-status="baseline"] {
+ background: var(--timeline-status-baseline);
+}
+
+.activity-timeline-bar[data-status="burst"] {
+ background: var(--timeline-status-burst);
+ box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
+}
+
+.activity-timeline-bar[data-status="flagged"] {
+ background: var(--timeline-status-flagged);
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
+ animation: timeline-flagged-pulse 2s ease-in-out infinite;
+}
+
+@keyframes timeline-flagged-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.activity-timeline-lane:hover .activity-timeline-bar {
+ opacity: 0.9;
+}
+
+/* ============================================
+ EXPANDED VIEW (tick marks)
+ ============================================ */
+.activity-timeline-ticks {
+ display: none;
+ position: relative;
+ height: 24px;
+ margin-top: 4px;
+ border-top: 1px solid var(--timeline-border);
+ padding-top: 4px;
+}
+
+.activity-timeline-lane.expanded .activity-timeline-ticks {
+ display: block;
+}
+
+.activity-timeline-tick {
+ position: absolute;
+ bottom: 0;
+ width: 1px;
+ background: var(--timeline-accent);
+}
+
+.activity-timeline-tick[data-strength="1"] { height: 4px; }
+.activity-timeline-tick[data-strength="2"] { height: 8px; }
+.activity-timeline-tick[data-strength="3"] { height: 12px; }
+.activity-timeline-tick[data-strength="4"] { height: 16px; }
+.activity-timeline-tick[data-strength="5"] { height: 20px; }
+
+/* ============================================
+ STATS COLUMN
+ ============================================ */
+.activity-timeline-stats {
+ width: 45px;
+ min-width: 45px;
+ padding: 4px 6px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-end;
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+ border-left: 1px solid var(--timeline-border);
+}
+
+.activity-timeline-stat-count {
+ color: var(--timeline-text-primary);
+ font-weight: 500;
+}
+
+.activity-timeline-stat-label {
+ font-size: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+/* ============================================
+ ANNOTATIONS
+ ============================================ */
+.activity-timeline-annotations {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--timeline-border);
+ max-height: 80px;
+ overflow-y: auto;
+}
+
+.activity-timeline-annotation {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ font-size: 10px;
+ color: var(--timeline-text-secondary);
+ background: var(--timeline-bg-secondary);
+ border-radius: 3px;
+ margin-bottom: 4px;
+}
+
+.activity-timeline-annotation-icon {
+ font-size: 10px;
+ width: 14px;
+ text-align: center;
+}
+
+.activity-timeline-annotation[data-type="new"] {
+ border-left: 2px solid var(--timeline-status-new);
+}
+
+.activity-timeline-annotation[data-type="burst"] {
+ border-left: 2px solid var(--timeline-status-burst);
+}
+
+.activity-timeline-annotation[data-type="pattern"] {
+ border-left: 2px solid var(--timeline-accent);
+}
+
+.activity-timeline-annotation[data-type="flagged"] {
+ border-left: 2px solid var(--timeline-status-flagged);
+ color: var(--timeline-status-flagged);
+}
+
+.activity-timeline-annotation[data-type="gone"] {
+ border-left: 2px solid var(--timeline-status-gone);
+}
+
+/* ============================================
+ TOOLTIP
+ ============================================ */
+.activity-timeline-tooltip {
+ position: fixed;
+ z-index: 10000;
+ background: var(--timeline-bg-elevated);
+ border: 1px solid var(--timeline-border);
+ border-radius: 4px;
+ padding: 8px 10px;
+ font-size: 10px;
+ color: var(--timeline-text-primary);
+ pointer-events: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ max-width: 240px;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.activity-timeline-tooltip-header {
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--timeline-accent);
+}
+
+.activity-timeline-tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--timeline-text-secondary);
+ line-height: 1.5;
+}
+
+.activity-timeline-tooltip-row span:last-child {
+ color: var(--timeline-text-primary);
+}
+
+/* ============================================
+ LEGEND
+ ============================================ */
+.activity-timeline-legend {
+ display: flex;
+ gap: 12px;
+ padding-top: 8px;
+ margin-top: 8px;
+ border-top: 1px solid var(--timeline-border);
+ font-size: 9px;
+ color: var(--timeline-text-dim);
+}
+
+.activity-timeline-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.activity-timeline-legend-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 2px;
+}
+
+.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
+.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
+.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
+.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
+
+/* ============================================
+ EMPTY STATE
+ ============================================ */
+.activity-timeline-empty {
+ text-align: center;
+ padding: 24px 16px;
+ color: var(--timeline-text-dim);
+ font-size: 11px;
+}
+
+.activity-timeline-empty-icon {
+ font-size: 20px;
+ margin-bottom: 8px;
+ opacity: 0.4;
+}
+
+/* More indicator */
+.activity-timeline-more {
+ text-align: center;
+ padding: 8px;
+ font-size: 10px;
+ color: var(--timeline-text-dim);
+}
+
+/* ============================================
+ VISUAL MODE: COMPACT
+ ============================================ */
+.activity-timeline--compact .activity-timeline-lanes {
+ max-height: 140px;
+}
+
+.activity-timeline--compact .activity-timeline-lane {
+ min-height: 26px;
+}
+
+.activity-timeline--compact .activity-timeline-label {
+ width: 100px;
+ min-width: 100px;
+ padding: 4px 6px;
+}
+
+.activity-timeline--compact .activity-timeline-id {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-name {
+ font-size: 10px;
+ color: var(--timeline-text-secondary);
+}
+
+.activity-timeline--compact .activity-timeline-track {
+ min-height: 26px;
+}
+
+.activity-timeline--compact .activity-timeline-bar {
+ height: 10px !important;
+}
+
+.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
+.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
+
+.activity-timeline--compact .activity-timeline-stats {
+ width: 30px;
+ min-width: 30px;
+}
+
+.activity-timeline--compact .activity-timeline-stat-label {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-legend {
+ display: none;
+}
+
+.activity-timeline--compact .activity-timeline-axis {
+ padding-left: 110px;
+ padding-right: 40px;
+}
+
+/* ============================================
+ VISUAL MODE: SUMMARY
+ ============================================ */
+.activity-timeline--summary .activity-timeline-lanes {
+ max-height: 100px;
+}
+
+.activity-timeline--summary .activity-timeline-lane {
+ min-height: 20px;
+}
+
+.activity-timeline--summary .activity-timeline-label {
+ width: 80px;
+ min-width: 80px;
+ padding: 3px 6px;
+}
+
+.activity-timeline--summary .activity-timeline-id,
+.activity-timeline--summary .activity-timeline-name {
+ font-size: 9px;
+}
+
+.activity-timeline--summary .activity-timeline-status {
+ width: 3px;
+ min-width: 3px;
+}
+
+.activity-timeline--summary .activity-timeline-track {
+ min-height: 20px;
+}
+
+.activity-timeline--summary .activity-timeline-bar {
+ height: 8px !important;
+ border-radius: 1px;
+}
+
+.activity-timeline--summary .activity-timeline-stats {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-ticks {
+ display: none !important;
+}
+
+.activity-timeline--summary .activity-timeline-annotations {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-legend {
+ display: none;
+}
+
+.activity-timeline--summary .activity-timeline-axis {
+ padding-left: 90px;
+ padding-right: 10px;
+ font-size: 8px;
+}
+
+/* ============================================
+ BACKWARD COMPATIBILITY NOTE
+ The old signal-timeline.css is still loaded
+ for existing TSCM code that uses those classes.
+ New code should use activity-timeline classes.
+ ============================================ */
diff --git a/static/js/components/activity-timeline.js b/static/js/components/activity-timeline.js
new file mode 100644
index 0000000..c0eeeb7
--- /dev/null
+++ b/static/js/components/activity-timeline.js
@@ -0,0 +1,1233 @@
+/**
+ * Activity Timeline Component
+ * Reusable, configuration-driven timeline visualization for time-based metadata
+ * Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
+ */
+
+const ActivityTimeline = (function() {
+ 'use strict';
+
+ // Default configuration
+ const defaults = {
+ // Identity
+ title: 'Activity Timeline',
+ mode: 'generic',
+
+ // Display options
+ visualMode: 'enriched', // 'compact' | 'enriched' | 'summary'
+ collapsed: true,
+ showAnnotations: true,
+ showLegend: true,
+
+ // Time configuration
+ timeWindows: {
+ '5m': 5 * 60 * 1000,
+ '15m': 15 * 60 * 1000,
+ '30m': 30 * 60 * 1000,
+ '1h': 60 * 60 * 1000,
+ '2h': 2 * 60 * 60 * 1000,
+ '4h': 4 * 60 * 60 * 1000,
+ '8h': 8 * 60 * 60 * 1000
+ },
+ defaultWindow: '30m',
+ availableWindows: ['5m', '15m', '30m', '1h', '2h'],
+
+ // Filter configuration
+ filters: {
+ hideBaseline: { enabled: true, label: 'Hide Known', default: false },
+ showOnlyNew: { enabled: true, label: 'New Only', default: false },
+ showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
+ },
+ customFilters: [],
+
+ // Limits
+ maxItems: 100,
+ maxDisplayedLanes: 15,
+ burstThreshold: 5,
+ burstWindow: 60 * 1000,
+ updateInterval: 5000,
+ barMinWidth: 2,
+
+ // Callbacks
+ onItemClick: null,
+ onItemFlag: null,
+ onExport: null,
+
+ // Label generator (can be overridden)
+ labelGenerator: null
+ };
+
+ // Instances registry for multi-instance support
+ const instances = new Map();
+
+ /**
+ * Create a new timeline instance
+ */
+ function create(containerId, options = {}) {
+ const container = document.getElementById(containerId);
+ if (!container) {
+ console.error(`ActivityTimeline: Container '${containerId}' not found`);
+ return null;
+ }
+
+ // Merge options with defaults
+ const config = mergeConfig(defaults, options);
+
+ // Create instance state
+ const state = {
+ containerId: containerId,
+ config: config,
+ items: new Map(),
+ annotations: [],
+ filterState: initFilterState(config),
+ timeWindow: config.defaultWindow,
+ tooltip: null,
+ updateTimer: null,
+ element: null
+ };
+
+ // Store instance
+ instances.set(containerId, state);
+
+ // Build DOM
+ state.element = buildTimeline(container, state);
+
+ // Setup interactions
+ setupEventListeners(state);
+
+ // Create tooltip
+ createTooltip(state);
+
+ // Start update cycle
+ startUpdateTimer(state);
+
+ // Initial render
+ render(state);
+
+ // Return public API bound to this instance
+ return createPublicAPI(containerId);
+ }
+
+ /**
+ * Merge user config with defaults
+ */
+ function mergeConfig(defaults, options) {
+ const config = { ...defaults };
+
+ for (const key of Object.keys(options)) {
+ if (typeof options[key] === 'object' && !Array.isArray(options[key]) && options[key] !== null) {
+ config[key] = { ...defaults[key], ...options[key] };
+ } else {
+ config[key] = options[key];
+ }
+ }
+
+ return config;
+ }
+
+ /**
+ * Initialize filter state from config
+ */
+ function initFilterState(config) {
+ const state = {};
+ for (const [key, filter] of Object.entries(config.filters)) {
+ if (filter.enabled) {
+ state[key] = filter.default || false;
+ }
+ }
+ for (const filter of config.customFilters) {
+ state[filter.key] = filter.default || false;
+ }
+ return state;
+ }
+
+ /**
+ * Create item data structure
+ */
+ function createItem(id, label, options = {}) {
+ return {
+ id: id,
+ label: label,
+ type: options.type || 'generic',
+ events: [],
+ firstSeen: null,
+ lastSeen: null,
+ status: 'new',
+ pattern: null,
+ flagged: false,
+ eventCount: 0,
+ tags: options.tags || [],
+ metadata: options.metadata || {}
+ };
+ }
+
+ /**
+ * Generate label for an item (can be overridden via config)
+ */
+ function generateLabel(id, state) {
+ if (state.config.labelGenerator) {
+ return state.config.labelGenerator(id);
+ }
+ return categorizeById(id, state.config.mode);
+ }
+
+ /**
+ * Default categorization by mode
+ */
+ function categorizeById(id, mode) {
+ // RF frequency categorization
+ if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
+ const f = parseFloat(id);
+ if (!isNaN(f)) {
+ 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 `${f.toFixed(3)} MHz`;
+ }
+ }
+
+ // Bluetooth mode - MAC address
+ if (mode === 'bluetooth') {
+ if (/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(id)) {
+ return id.substring(0, 8) + '...';
+ }
+ }
+
+ // WiFi mode - SSID
+ if (mode === 'wifi') {
+ if (id.length > 20) {
+ return id.substring(0, 17) + '...';
+ }
+ }
+
+ return id;
+ }
+
+ /**
+ * Add or update an event
+ */
+ function addEvent(containerId, eventData) {
+ const state = instances.get(containerId);
+ if (!state) return null;
+
+ const now = Date.now();
+ const id = eventData.id;
+ const label = eventData.label || generateLabel(id, state);
+
+ let item = state.items.get(id);
+
+ if (!item) {
+ item = createItem(id, label, {
+ type: eventData.type,
+ tags: eventData.tags,
+ metadata: eventData.metadata
+ });
+ item.firstSeen = now;
+ state.items.set(id, item);
+
+ addAnnotation(state, 'new', `New: ${item.label}`, now);
+ }
+
+ // Add event
+ item.events.push({
+ timestamp: now,
+ strength: Math.min(5, Math.max(1, eventData.strength || 3)),
+ duration: eventData.duration || 1000
+ });
+
+ item.lastSeen = now;
+ item.eventCount++;
+
+ // Update status
+ updateItemStatus(item, state.config);
+
+ // Detect patterns
+ detectPatterns(item, state);
+
+ // Prune old events
+ const windowMs = state.config.timeWindows['8h'] || state.config.timeWindows['2h'];
+ item.events = item.events.filter(e => now - e.timestamp < windowMs);
+
+ // Prune items if over limit
+ if (state.items.size > state.config.maxItems) {
+ pruneOldItems(state);
+ }
+
+ return item;
+ }
+
+ /**
+ * Bulk import events
+ */
+ function importEvents(containerId, events) {
+ for (const event of events) {
+ addEvent(containerId, event);
+ }
+ }
+
+ /**
+ * Remove oldest/least active items
+ */
+ function pruneOldItems(state) {
+ const items = Array.from(state.items.entries());
+ items.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;
+ });
+
+ const toRemove = items.length - state.config.maxItems;
+ for (let i = 0; i < toRemove; i++) {
+ if (!items[i][1].flagged) {
+ state.items.delete(items[i][0]);
+ }
+ }
+ }
+
+ /**
+ * Update item status based on activity
+ */
+ function updateItemStatus(item, config) {
+ const now = Date.now();
+ const recentEvents = item.events.filter(
+ e => now - e.timestamp < config.burstWindow
+ );
+
+ if (recentEvents.length >= config.burstThreshold) {
+ if (item.status !== 'burst') {
+ item.status = 'burst';
+ }
+ } else if (item.eventCount >= 20) {
+ item.status = 'baseline';
+ } else if (now - item.firstSeen < 5 * 60 * 1000) {
+ item.status = 'new';
+ }
+
+ if (item.flagged) {
+ item.status = 'flagged';
+ }
+ }
+
+ /**
+ * Detect repeating patterns
+ */
+ function detectPatterns(item, state) {
+ if (item.events.length < 4) return;
+
+ const intervals = [];
+ for (let i = 1; i < item.events.length; i++) {
+ intervals.push(item.events[i].timestamp - item.events[i-1].timestamp);
+ }
+
+ 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 (item.pattern !== patternStr) {
+ item.pattern = patternStr;
+ addAnnotation(state, 'pattern', `Pattern: ${patternStr} - ${item.label}`, Date.now());
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Add annotation
+ */
+ function addAnnotation(state, type, message, timestamp) {
+ state.annotations.unshift({
+ type: type,
+ message: message,
+ timestamp: timestamp
+ });
+
+ if (state.annotations.length > 20) {
+ state.annotations.pop();
+ }
+ }
+
+ /**
+ * Toggle flag on item
+ */
+ function toggleFlag(containerId, id) {
+ const state = instances.get(containerId);
+ if (!state) return;
+
+ const item = state.items.get(id);
+ if (item) {
+ item.flagged = !item.flagged;
+ item.status = item.flagged ? 'flagged' : 'new';
+ addAnnotation(state,
+ 'flagged',
+ item.flagged ? `Flagged: ${item.label}` : `Unflagged: ${item.label}`,
+ Date.now()
+ );
+
+ if (state.config.onItemFlag) {
+ state.config.onItemFlag(item);
+ }
+
+ render(state);
+ }
+ }
+
+ /**
+ * Mark item as inactive
+ */
+ function markInactive(containerId, id) {
+ const state = instances.get(containerId);
+ if (!state) return;
+
+ const item = state.items.get(id);
+ if (item && item.status !== 'gone') {
+ item.status = 'gone';
+ addAnnotation(state, 'gone', `Inactive: ${item.label}`, Date.now());
+ }
+ }
+
+ /**
+ * Build timeline DOM
+ */
+ function buildTimeline(container, state) {
+ const config = state.config;
+ const timeline = document.createElement('div');
+ timeline.className = `activity-timeline activity-timeline--${config.visualMode}` +
+ (config.collapsed ? ' collapsed' : '');
+ timeline.id = `activityTimeline-${state.containerId}`;
+ timeline.dataset.mode = config.mode;
+
+ // Build filter buttons HTML
+ const filterButtonsHtml = buildFilterButtons(config);
+
+ // Build window options HTML
+ const windowOptionsHtml = config.availableWindows.map(w =>
+ ``
+ ).join('');
+
+ // Build legend HTML
+ const legendHtml = config.showLegend ? `
+
+ ` : '';
+
+ timeline.innerHTML = `
+
+
+
+ ${filterButtonsHtml}
+
+ Window:
+
+
+
+
+
+
+
◯
+
No activity recorded
+
Events will appear as they are detected
+
+
+
+ ${legendHtml}
+
+ `;
+
+ container.appendChild(timeline);
+ return timeline;
+ }
+
+ /**
+ * Build filter buttons HTML
+ */
+ function buildFilterButtons(config) {
+ let html = '';
+
+ for (const [key, filter] of Object.entries(config.filters)) {
+ if (filter.enabled) {
+ html += ``;
+ }
+ }
+
+ for (const filter of config.customFilters) {
+ html += ``;
+ }
+
+ return html;
+ }
+
+ /**
+ * Format window label
+ */
+ function formatWindowLabel(window) {
+ const labels = {
+ '5m': '5 min',
+ '15m': '15 min',
+ '30m': '30 min',
+ '1h': '1 hour',
+ '2h': '2 hours',
+ '4h': '4 hours',
+ '8h': '8 hours'
+ };
+ return labels[window] || window;
+ }
+
+ /**
+ * Setup event listeners
+ */
+ function setupEventListeners(state) {
+ const timeline = state.element;
+
+ // Collapse toggle
+ const header = timeline.querySelector('.activity-timeline-header');
+ if (header) {
+ header.addEventListener('click', (e) => {
+ if (e.target.closest('button') || e.target.closest('select')) return;
+ timeline.classList.toggle('collapsed');
+ });
+ }
+
+ // Filter buttons
+ timeline.querySelectorAll('.activity-timeline-btn[data-filter]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const filter = btn.dataset.filter;
+ state.filterState[filter] = !state.filterState[filter];
+ btn.classList.toggle('active', state.filterState[filter]);
+ render(state);
+ });
+ });
+
+ // Time window selector
+ const windowSelect = timeline.querySelector('.activity-timeline-window-select');
+ if (windowSelect) {
+ windowSelect.addEventListener('click', (e) => e.stopPropagation());
+ windowSelect.addEventListener('change', (e) => {
+ state.timeWindow = e.target.value;
+ render(state);
+ });
+ }
+
+ // Lane interactions
+ timeline.addEventListener('click', (e) => {
+ const lane = e.target.closest('.activity-timeline-lane');
+ if (lane && !e.target.closest('button')) {
+ lane.classList.toggle('expanded');
+
+ if (state.config.onItemClick) {
+ const id = lane.dataset.id;
+ const item = state.items.get(id);
+ if (item) {
+ state.config.onItemClick(item);
+ }
+ }
+ }
+ });
+
+ // Right-click to flag
+ timeline.addEventListener('contextmenu', (e) => {
+ const lane = e.target.closest('.activity-timeline-lane');
+ if (lane) {
+ e.preventDefault();
+ const id = lane.dataset.id;
+ toggleFlag(state.containerId, id);
+ }
+ });
+ }
+
+ /**
+ * Create tooltip element
+ */
+ function createTooltip(state) {
+ if (state.tooltip) return;
+
+ state.tooltip = document.createElement('div');
+ state.tooltip.className = 'activity-timeline-tooltip';
+ state.tooltip.style.display = 'none';
+ document.body.appendChild(state.tooltip);
+ }
+
+ /**
+ * Show tooltip
+ */
+ function showTooltip(e, item, state) {
+ if (!state.tooltip) return;
+
+ const lastSeenStr = formatTimeAgo(item.lastSeen);
+
+ state.tooltip.innerHTML = `
+
+
+ ID:
+ ${item.id}
+
+
+ First seen:
+ ${formatTime(item.firstSeen)}
+
+
+ Last seen:
+ ${lastSeenStr}
+
+
+ Events:
+ ${item.eventCount}
+
+ ${item.pattern ? `
+
+ Pattern:
+ ${item.pattern}
+
+ ` : ''}
+
+ Status:
+ ${item.status}
+
+ ${item.tags.length > 0 ? `
+
+ Tags:
+ ${item.tags.join(', ')}
+
+ ` : ''}
+ `;
+
+ 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(state) {
+ if (state.tooltip) {
+ state.tooltip.style.display = 'none';
+ }
+ }
+
+ /**
+ * Start update timer
+ */
+ function startUpdateTimer(state) {
+ if (state.updateTimer) {
+ clearInterval(state.updateTimer);
+ }
+ state.updateTimer = setInterval(() => {
+ render(state);
+ }, state.config.updateInterval);
+ }
+
+ /**
+ * Stop update timer
+ */
+ function stopUpdateTimer(state) {
+ if (state.updateTimer) {
+ clearInterval(state.updateTimer);
+ state.updateTimer = null;
+ }
+ }
+
+ /**
+ * Render the timeline
+ */
+ function render(state) {
+ const lanesContainer = state.element.querySelector('.activity-timeline-lanes');
+ const axisContainer = state.element.querySelector('.activity-timeline-axis');
+ const annotationsContainer = state.element.querySelector('.activity-timeline-annotations');
+
+ if (!lanesContainer) return;
+
+ const now = Date.now();
+ const windowMs = state.config.timeWindows[state.timeWindow];
+ const startTime = now - windowMs;
+
+ // Render time axis
+ renderAxis(axisContainer, startTime, now, windowMs);
+
+ // Get filtered items
+ let items = Array.from(state.items.values());
+
+ // Apply standard filters
+ if (state.filterState.hideBaseline) {
+ items = items.filter(s => s.status !== 'baseline');
+ }
+ if (state.filterState.showOnlyNew) {
+ items = items.filter(s => s.status === 'new');
+ }
+ if (state.filterState.showOnlyBurst) {
+ items = items.filter(s => s.status === 'burst');
+ }
+
+ // Apply custom filters
+ for (const filter of state.config.customFilters) {
+ if (state.filterState[filter.key] && filter.predicate) {
+ items = items.filter(filter.predicate);
+ }
+ }
+
+ // Sort by priority and recency
+ const statusPriority = { flagged: 0, burst: 1, new: 2, baseline: 3, gone: 4 };
+ items.sort((a, b) => {
+ const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
+ if (priorityDiff !== 0) return priorityDiff;
+ return b.lastSeen - a.lastSeen;
+ });
+
+ // Render lanes
+ const totalItems = items.length;
+ const displayedItems = items.slice(0, state.config.maxDisplayedLanes);
+ const hiddenCount = totalItems - displayedItems.length;
+
+ if (items.length === 0) {
+ lanesContainer.innerHTML = `
+
+
◯
+
No activity recorded
+
Events will appear as they are detected
+
+ `;
+ } else {
+ let html = displayedItems.map(item =>
+ renderLane(item, startTime, now, windowMs, state)
+ ).join('');
+
+ if (hiddenCount > 0) {
+ html += `
+
+ +${hiddenCount} more (scroll or adjust filters)
+
+ `;
+ }
+
+ lanesContainer.innerHTML = html;
+
+ // Add tooltip listeners
+ lanesContainer.querySelectorAll('.activity-timeline-lane').forEach(lane => {
+ const id = lane.dataset.id;
+ const item = state.items.get(id);
+
+ lane.addEventListener('mouseenter', (e) => showTooltip(e, item, state));
+ lane.addEventListener('mousemove', (e) => showTooltip(e, item, state));
+ lane.addEventListener('mouseleave', () => hideTooltip(state));
+ });
+ }
+
+ // Update header stats
+ const allItems = Array.from(state.items.values());
+ const statTotal = state.element.querySelector('[data-stat="total"]');
+ const statNew = state.element.querySelector('[data-stat="new"]');
+ const statBurst = state.element.querySelector('[data-stat="burst"]');
+ if (statTotal) statTotal.textContent = allItems.length;
+ if (statNew) statNew.textContent = allItems.filter(s => s.status === 'new').length;
+ if (statBurst) statBurst.textContent = allItems.filter(s => s.status === 'burst').length;
+
+ // Render annotations
+ if (state.config.showAnnotations) {
+ renderAnnotations(annotationsContainer, state);
+ }
+ }
+
+ /**
+ * 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(item, startTime, endTime, windowMs, state) {
+ const isBaseline = item.status === 'baseline';
+ const visualMode = state.config.visualMode;
+
+ // Get events within time window
+ const visibleEvents = item.events.filter(
+ e => e.timestamp >= startTime && e.timestamp <= endTime
+ );
+
+ // Generate bars
+ const barsHtml = aggregateAndRenderBars(visibleEvents, startTime, windowMs, state.config);
+
+ // Generate ticks for expanded view
+ const ticksHtml = visibleEvents.map(event => {
+ const position = ((event.timestamp - startTime) / windowMs) * 100;
+ return ``;
+ }).join('');
+
+ const recentCount = visibleEvents.length;
+
+ // Compact mode: minimal info
+ if (visualMode === 'compact') {
+ return `
+
+
+
+ ${item.label}
+
+
+
+ ${recentCount}
+
+
+ `;
+ }
+
+ // Enriched mode: full info
+ return `
+
+
+
+ ${item.id}
+ ${item.label}
+
+
+
${barsHtml}
+
${ticksHtml}
+
+
+ ${recentCount}
+ events
+
+
+ `;
+ }
+
+ /**
+ * Aggregate events into bars
+ */
+ function aggregateAndRenderBars(events, startTime, windowMs, config) {
+ if (events.length === 0) return '';
+
+ const bars = [];
+ let currentBar = null;
+ const minGap = windowMs / 100;
+
+ 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) {
+ currentBar.end = Math.max(currentBar.end, event.timestamp + event.duration);
+ currentBar.maxStrength = Math.max(currentBar.maxStrength, event.strength);
+ currentBar.count++;
+ } else {
+ bars.push(currentBar);
+ currentBar = {
+ start: event.timestamp,
+ end: event.timestamp + event.duration,
+ maxStrength: event.strength,
+ count: 1
+ };
+ }
+ }
+ if (currentBar) bars.push(currentBar);
+
+ return bars.map(bar => {
+ const left = ((bar.start - startTime) / windowMs) * 100;
+ const width = Math.max(
+ config.barMinWidth / 8,
+ ((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, state) {
+ if (!container) return;
+
+ const recentAnnotations = state.annotations.slice(0, 5);
+
+ if (recentAnnotations.length === 0) {
+ container.style.display = 'none';
+ return;
+ }
+
+ container.style.display = 'block';
+
+ const iconMap = {
+ new: '●',
+ burst: '◆',
+ pattern: '↻',
+ flagged: '⚑',
+ gone: '○'
+ };
+
+ container.innerHTML = recentAnnotations.map(ann => {
+ const icon = iconMap[ann.type] || '•';
+ return `
+
+ ${icon}
+ ${ann.message}
+ ${formatTimeAgo(ann.timestamp)}
+
+ `;
+ }).join('');
+ }
+
+ /**
+ * Format time
+ */
+ 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) {
+ return new Date(timestamp).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`;
+ }
+
+ /**
+ * Clear all data
+ */
+ function clear(containerId) {
+ const state = instances.get(containerId);
+ if (!state) return;
+
+ state.items.clear();
+ state.annotations = [];
+ render(state);
+ }
+
+ /**
+ * Export data
+ */
+ function exportData(containerId) {
+ const state = instances.get(containerId);
+ if (!state) return null;
+
+ const items = Array.from(state.items.values()).map(s => ({
+ id: s.id,
+ label: s.label,
+ type: s.type,
+ status: s.status,
+ pattern: s.pattern,
+ firstSeen: new Date(s.firstSeen).toISOString(),
+ lastSeen: new Date(s.lastSeen).toISOString(),
+ eventCount: s.eventCount,
+ flagged: s.flagged,
+ tags: s.tags
+ }));
+
+ return {
+ exportTime: new Date().toISOString(),
+ mode: state.config.mode,
+ timeWindow: state.timeWindow,
+ items: items,
+ annotations: state.annotations.map(a => ({
+ ...a,
+ timestamp: new Date(a.timestamp).toISOString()
+ }))
+ };
+ }
+
+ /**
+ * Get stats
+ */
+ function getStats(containerId) {
+ const state = instances.get(containerId);
+ if (!state) return null;
+
+ const items = Array.from(state.items.values());
+ return {
+ total: items.length,
+ new: items.filter(s => s.status === 'new').length,
+ baseline: items.filter(s => s.status === 'baseline').length,
+ burst: items.filter(s => s.status === 'burst').length,
+ flagged: items.filter(s => s.flagged).length,
+ withPattern: items.filter(s => s.pattern).length
+ };
+ }
+
+ /**
+ * Destroy instance
+ */
+ function destroy(containerId) {
+ const state = instances.get(containerId);
+ if (!state) return;
+
+ stopUpdateTimer(state);
+
+ if (state.tooltip) {
+ state.tooltip.remove();
+ state.tooltip = null;
+ }
+
+ if (state.element) {
+ state.element.remove();
+ }
+
+ instances.delete(containerId);
+ }
+
+ /**
+ * Create public API for an instance
+ */
+ function createPublicAPI(containerId) {
+ return {
+ // Data management
+ addEvent: (eventData) => addEvent(containerId, eventData),
+ importEvents: (events) => importEvents(containerId, events),
+ toggleFlag: (id) => toggleFlag(containerId, id),
+ markInactive: (id) => markInactive(containerId, id),
+ clear: () => clear(containerId),
+
+ // Rendering
+ render: () => {
+ const state = instances.get(containerId);
+ if (state) render(state);
+ },
+
+ // Data access
+ getItems: () => {
+ const state = instances.get(containerId);
+ return state ? Array.from(state.items.values()) : [];
+ },
+ getAnnotations: () => {
+ const state = instances.get(containerId);
+ return state ? state.annotations : [];
+ },
+ getStats: () => getStats(containerId),
+ exportData: () => exportData(containerId),
+
+ // Configuration
+ setTimeWindow: (window) => {
+ const state = instances.get(containerId);
+ if (state && state.config.timeWindows[window]) {
+ state.timeWindow = window;
+ render(state);
+ }
+ },
+ setFilter: (filter, value) => {
+ const state = instances.get(containerId);
+ if (state && state.filterState.hasOwnProperty(filter)) {
+ state.filterState[filter] = value;
+ render(state);
+ }
+ },
+
+ // Lifecycle
+ destroy: () => destroy(containerId)
+ };
+ }
+
+ // Global API
+ return {
+ create: create,
+
+ // Convenience methods for single-instance use
+ addEvent: (containerId, eventData) => addEvent(containerId, eventData),
+ clear: (containerId) => clear(containerId),
+ getStats: (containerId) => getStats(containerId),
+ exportData: (containerId) => exportData(containerId),
+ destroy: (containerId) => destroy(containerId),
+
+ // Instance access
+ getInstance: (containerId) => instances.get(containerId),
+ getInstances: () => instances
+ };
+})();
+
+// Backwards compatibility alias
+window.ActivityTimeline = ActivityTimeline;
+
+// Legacy SignalTimeline compatibility wrapper
+window.SignalTimeline = (function() {
+ 'use strict';
+
+ let legacyInstance = null;
+ const LEGACY_CONTAINER = 'signalTimelineContainer';
+
+ return {
+ create: function(containerId, options = {}) {
+ // Map old options to new format
+ const newOptions = {
+ title: 'Signal Activity Timeline',
+ mode: 'tscm',
+ visualMode: 'enriched',
+ collapsed: options.collapsed !== false,
+ showAnnotations: true,
+ showLegend: true
+ };
+
+ legacyInstance = ActivityTimeline.create(containerId, newOptions);
+ return legacyInstance ? document.getElementById(`activityTimeline-${containerId}`) : null;
+ },
+
+ destroy: function() {
+ if (legacyInstance) {
+ legacyInstance.destroy();
+ legacyInstance = null;
+ }
+ },
+
+ addEvent: function(frequency, strength = 3, duration = 1000, name = null) {
+ if (legacyInstance) {
+ return legacyInstance.addEvent({
+ id: String(frequency),
+ label: name,
+ strength: strength,
+ duration: duration,
+ type: 'rf'
+ });
+ }
+ return null;
+ },
+
+ flagSignal: function(frequency) {
+ if (legacyInstance) {
+ legacyInstance.toggleFlag(String(frequency));
+ }
+ },
+
+ markGone: function(frequency) {
+ if (legacyInstance) {
+ legacyInstance.markInactive(String(frequency));
+ }
+ },
+
+ clear: function() {
+ if (legacyInstance) {
+ legacyInstance.clear();
+ }
+ },
+
+ render: function() {
+ if (legacyInstance) {
+ legacyInstance.render();
+ }
+ },
+
+ getSignals: function() {
+ return legacyInstance ? legacyInstance.getItems() : [];
+ },
+
+ getAnnotations: function() {
+ return legacyInstance ? legacyInstance.getAnnotations() : [];
+ },
+
+ getStats: function() {
+ return legacyInstance ? legacyInstance.getStats() : null;
+ },
+
+ exportData: function() {
+ return legacyInstance ? legacyInstance.exportData() : null;
+ },
+
+ setTimeWindow: function(window) {
+ if (legacyInstance) {
+ legacyInstance.setTimeWindow(window);
+ }
+ },
+
+ setFilter: function(filter, value) {
+ if (legacyInstance) {
+ legacyInstance.setFilter(filter, value);
+ }
+ }
+ };
+})();
diff --git a/static/js/components/timeline-adapters/bluetooth-adapter.js b/static/js/components/timeline-adapters/bluetooth-adapter.js
new file mode 100644
index 0000000..f0263d1
--- /dev/null
+++ b/static/js/components/timeline-adapters/bluetooth-adapter.js
@@ -0,0 +1,288 @@
+/**
+ * Bluetooth Timeline Adapter
+ * Normalizes Bluetooth device data for the Activity Timeline component
+ * Used by: Bluetooth mode, TSCM (Bluetooth detections)
+ */
+
+const BluetoothTimelineAdapter = (function() {
+ 'use strict';
+
+ /**
+ * RSSI to strength category mapping for Bluetooth
+ * Bluetooth RSSI typically ranges from -30 (very close) to -100 (far)
+ */
+ const RSSI_THRESHOLDS = {
+ VERY_STRONG: -45, // 5 - device likely within 1m
+ STRONG: -60, // 4 - device likely within 3m
+ MODERATE: -75, // 3 - device likely within 10m
+ WEAK: -90, // 2 - device at edge of range
+ MINIMAL: -100 // 1 - barely detectable
+ };
+
+ /**
+ * Known device type patterns
+ */
+ const DEVICE_PATTERNS = {
+ // Apple devices
+ AIRPODS: /airpods/i,
+ IPHONE: /iphone/i,
+ IPAD: /ipad/i,
+ MACBOOK: /macbook|mac\s*pro|imac/i,
+ APPLE_WATCH: /apple\s*watch/i,
+ AIRTAG: /airtag/i,
+
+ // Trackers
+ TILE: /tile/i,
+ CHIPOLO: /chipolo/i,
+ SAMSUNG_TAG: /smarttag|galaxy\s*tag/i,
+
+ // Audio
+ HEADPHONES: /headphone|earphone|earbud|bose|sony|beats|jabra|sennheiser/i,
+ SPEAKER: /speaker|soundbar|echo|homepod|sonos/i,
+
+ // Wearables
+ FITBIT: /fitbit/i,
+ GARMIN: /garmin/i,
+ SMARTWATCH: /watch|band|mi\s*band|galaxy\s*fit/i,
+
+ // Input devices
+ KEYBOARD: /keyboard/i,
+ MOUSE: /mouse|trackpad|magic/i,
+ CONTROLLER: /controller|gamepad|xbox|playstation|dualshock/i,
+
+ // Vehicles
+ CAR: /car\s*kit|handsfree|obd|vehicle|toyota|honda|ford|bmw|mercedes/i
+ };
+
+ /**
+ * Convert RSSI to strength category
+ */
+ function rssiToStrength(rssi) {
+ if (rssi === null || rssi === undefined) return 3;
+
+ const r = parseFloat(rssi);
+ if (isNaN(r)) return 3;
+
+ if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
+ if (r > RSSI_THRESHOLDS.STRONG) return 4;
+ if (r > RSSI_THRESHOLDS.MODERATE) return 3;
+ if (r > RSSI_THRESHOLDS.WEAK) return 2;
+ return 1;
+ }
+
+ /**
+ * Classify device type from name
+ */
+ function classifyDevice(name) {
+ if (!name) return { type: 'unknown', category: 'device' };
+
+ for (const [pattern, regex] of Object.entries(DEVICE_PATTERNS)) {
+ if (regex.test(name)) {
+ return {
+ type: pattern.toLowerCase(),
+ category: getCategoryForType(pattern)
+ };
+ }
+ }
+
+ return { type: 'unknown', category: 'device' };
+ }
+
+ /**
+ * Get category for device type
+ */
+ function getCategoryForType(type) {
+ const categories = {
+ AIRPODS: 'audio',
+ IPHONE: 'phone',
+ IPAD: 'tablet',
+ MACBOOK: 'computer',
+ APPLE_WATCH: 'wearable',
+ AIRTAG: 'tracker',
+ TILE: 'tracker',
+ CHIPOLO: 'tracker',
+ SAMSUNG_TAG: 'tracker',
+ HEADPHONES: 'audio',
+ SPEAKER: 'audio',
+ FITBIT: 'wearable',
+ GARMIN: 'wearable',
+ SMARTWATCH: 'wearable',
+ KEYBOARD: 'input',
+ MOUSE: 'input',
+ CONTROLLER: 'input',
+ CAR: 'vehicle'
+ };
+ return categories[type] || 'device';
+ }
+
+ /**
+ * Format MAC address for display (truncated)
+ */
+ function formatMac(mac, full = false) {
+ if (!mac) return 'Unknown';
+ if (full) return mac.toUpperCase();
+ return mac.substring(0, 8).toUpperCase() + '...';
+ }
+
+ /**
+ * Determine if device is a tracker type
+ */
+ function isTracker(device) {
+ if (device.is_tracker) return true;
+
+ const name = device.name || '';
+ return /airtag|tile|chipolo|smarttag|tracker/i.test(name);
+ }
+
+ /**
+ * Normalize a Bluetooth device detection for the timeline
+ */
+ function normalizeDevice(device) {
+ const mac = device.mac || device.address || device.id;
+ const name = device.name || device.device_name || formatMac(mac);
+ const classification = classifyDevice(name);
+
+ const tags = [device.type || 'ble'];
+ tags.push(classification.category);
+
+ if (isTracker(device)) tags.push('tracker');
+ if (device.is_beacon) tags.push('beacon');
+ if (device.is_connectable) tags.push('connectable');
+ if (device.manufacturer) tags.push('identified');
+
+ return {
+ id: mac,
+ label: name,
+ strength: rssiToStrength(device.rssi),
+ duration: device.scan_duration || device.duration || 1000,
+ type: classification.type,
+ tags: tags,
+ metadata: {
+ mac: mac,
+ rssi: device.rssi,
+ device_type: device.type,
+ manufacturer: device.manufacturer,
+ services: device.services,
+ is_tracker: isTracker(device),
+ classification: classification
+ }
+ };
+ }
+
+ /**
+ * Normalize for TSCM context (includes threat assessment)
+ */
+ function normalizeTscmDevice(device) {
+ const normalized = normalizeDevice(device);
+
+ // Add TSCM-specific tags
+ if (device.is_new) normalized.tags.push('new');
+ if (device.threat_level) normalized.tags.push(`threat-${device.threat_level}`);
+ if (device.baseline_known === false) normalized.tags.push('unknown');
+
+ normalized.metadata.threat_level = device.threat_level;
+ normalized.metadata.first_seen = device.first_seen;
+ normalized.metadata.appearance_count = device.appearance_count;
+
+ return normalized;
+ }
+
+ /**
+ * Batch normalize multiple devices
+ */
+ function normalizeDevices(devices, context = 'scan') {
+ const normalizer = context === 'tscm' ? normalizeTscmDevice : normalizeDevice;
+ return devices.map(normalizer);
+ }
+
+ /**
+ * Create timeline configuration for Bluetooth mode
+ */
+ function getBluetoothConfig() {
+ return {
+ title: 'Device Activity',
+ mode: 'bluetooth',
+ visualMode: 'enriched',
+ collapsed: false,
+ showAnnotations: true,
+ showLegend: true,
+ defaultWindow: '15m',
+ availableWindows: ['5m', '15m', '30m', '1h'],
+ filters: {
+ hideBaseline: { enabled: true, label: 'Hide Known', default: false },
+ showOnlyNew: { enabled: true, label: 'New Only', default: false },
+ showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
+ },
+ customFilters: [
+ {
+ key: 'showOnlyTrackers',
+ label: 'Trackers Only',
+ default: false,
+ predicate: (item) => item.tags.includes('tracker')
+ },
+ {
+ key: 'hideWearables',
+ label: 'Hide Wearables',
+ default: false,
+ predicate: (item) => !item.tags.includes('wearable')
+ }
+ ],
+ maxItems: 75,
+ maxDisplayedLanes: 12,
+ labelGenerator: (id) => formatMac(id)
+ };
+ }
+
+ /**
+ * Create compact timeline configuration (for sidebar use)
+ */
+ function getCompactConfig() {
+ return {
+ title: 'BT Devices',
+ mode: 'bluetooth',
+ visualMode: 'compact',
+ collapsed: false,
+ showAnnotations: false,
+ showLegend: false,
+ defaultWindow: '15m',
+ availableWindows: ['5m', '15m', '30m'],
+ filters: {
+ hideBaseline: { enabled: false },
+ showOnlyNew: { enabled: true, label: 'New', default: false },
+ showOnlyBurst: { enabled: false }
+ },
+ customFilters: [],
+ maxItems: 30,
+ maxDisplayedLanes: 8
+ };
+ }
+
+ // Public API
+ return {
+ // Normalization
+ normalizeDevice: normalizeDevice,
+ normalizeTscmDevice: normalizeTscmDevice,
+ normalizeDevices: normalizeDevices,
+
+ // Utilities
+ rssiToStrength: rssiToStrength,
+ classifyDevice: classifyDevice,
+ formatMac: formatMac,
+ isTracker: isTracker,
+
+ // Configuration presets
+ getBluetoothConfig: getBluetoothConfig,
+ getCompactConfig: getCompactConfig,
+
+ // Constants
+ RSSI_THRESHOLDS: RSSI_THRESHOLDS,
+ DEVICE_PATTERNS: DEVICE_PATTERNS
+ };
+})();
+
+// Export for module systems
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = BluetoothTimelineAdapter;
+}
+
+window.BluetoothTimelineAdapter = BluetoothTimelineAdapter;
diff --git a/static/js/components/timeline-adapters/rf-adapter.js b/static/js/components/timeline-adapters/rf-adapter.js
new file mode 100644
index 0000000..972d16c
--- /dev/null
+++ b/static/js/components/timeline-adapters/rf-adapter.js
@@ -0,0 +1,241 @@
+/**
+ * RF Signal Timeline Adapter
+ * Normalizes RF signal data for the Activity Timeline component
+ * Used by: Listening Post, TSCM
+ */
+
+const RFTimelineAdapter = (function() {
+ 'use strict';
+
+ /**
+ * RSSI to strength category mapping
+ * Uses confidence-safe thresholds
+ */
+ const RSSI_THRESHOLDS = {
+ VERY_STRONG: -40, // 5 - indicates likely nearby source
+ STRONG: -55, // 4 - probable close proximity
+ MODERATE: -70, // 3 - likely in proximity
+ WEAK: -85, // 2 - potentially distant or obstructed
+ MINIMAL: -100 // 1 - may be ambient noise or distant source
+ };
+
+ /**
+ * Frequency band categorization
+ */
+ const FREQUENCY_BANDS = [
+ { min: 2400, max: 2500, label: 'Wi-Fi 2.4GHz', type: 'wifi' },
+ { min: 5150, max: 5850, label: 'Wi-Fi 5GHz', type: 'wifi' },
+ { min: 5925, max: 7125, label: 'Wi-Fi 6E', type: 'wifi' },
+ { min: 2402, max: 2480, label: 'Bluetooth', type: 'bluetooth' },
+ { min: 433, max: 434, label: '433MHz ISM', type: 'ism' },
+ { min: 868, max: 869, label: '868MHz ISM', type: 'ism' },
+ { min: 902, max: 928, label: '915MHz ISM', type: 'ism' },
+ { min: 315, max: 316, label: '315MHz', type: 'keyfob' },
+ { min: 144, max: 148, label: 'VHF Ham', type: 'amateur' },
+ { min: 420, max: 450, label: 'UHF Ham', type: 'amateur' },
+ { min: 462.5625, max: 467.7125, label: 'FRS/GMRS', type: 'personal' },
+ { min: 151, max: 159, label: 'VHF Business', type: 'commercial' },
+ { min: 450, max: 470, label: 'UHF Business', type: 'commercial' },
+ { min: 88, max: 108, label: 'FM Broadcast', type: 'broadcast' },
+ { min: 118, max: 137, label: 'Airband', type: 'aviation' },
+ { min: 156, max: 162, label: 'Marine VHF', type: 'marine' }
+ ];
+
+ /**
+ * Convert RSSI (dBm) to strength category (1-5)
+ */
+ function rssiToStrength(rssi) {
+ if (rssi === null || rssi === undefined) return 3;
+
+ const r = parseFloat(rssi);
+ if (isNaN(r)) return 3;
+
+ if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
+ if (r > RSSI_THRESHOLDS.STRONG) return 4;
+ if (r > RSSI_THRESHOLDS.MODERATE) return 3;
+ if (r > RSSI_THRESHOLDS.WEAK) return 2;
+ return 1;
+ }
+
+ /**
+ * Categorize frequency into human-readable band name
+ */
+ function categorizeFrequency(freqMHz) {
+ const f = parseFloat(freqMHz);
+ if (isNaN(f)) return { label: String(freqMHz), type: 'unknown' };
+
+ for (const band of FREQUENCY_BANDS) {
+ if (f >= band.min && f <= band.max) {
+ return { label: band.label, type: band.type };
+ }
+ }
+
+ // Generic labeling by range
+ if (f < 30) return { label: `${f.toFixed(3)} MHz HF`, type: 'hf' };
+ if (f < 300) return { label: `${f.toFixed(3)} MHz VHF`, type: 'vhf' };
+ if (f < 3000) return { label: `${f.toFixed(3)} MHz UHF`, type: 'uhf' };
+ return { label: `${f.toFixed(3)} MHz`, type: 'unknown' };
+ }
+
+ /**
+ * Normalize a scanner signal detection for the timeline
+ */
+ function normalizeSignal(signalData) {
+ const freq = signalData.frequency || signalData.freq;
+ const category = categorizeFrequency(freq);
+
+ return {
+ id: String(freq),
+ label: signalData.name || category.label,
+ strength: rssiToStrength(signalData.rssi || signalData.signal_strength),
+ duration: signalData.duration || 1000,
+ type: category.type,
+ tags: buildTags(signalData, category),
+ metadata: {
+ frequency: freq,
+ rssi: signalData.rssi,
+ modulation: signalData.modulation,
+ bandwidth: signalData.bandwidth
+ }
+ };
+ }
+
+ /**
+ * Normalize a TSCM RF detection
+ */
+ function normalizeTscmSignal(detection) {
+ const freq = detection.frequency;
+ const category = categorizeFrequency(freq);
+
+ const tags = buildTags(detection, category);
+
+ // Add TSCM-specific tags
+ if (detection.is_new) tags.push('new');
+ if (detection.baseline_deviation) tags.push('deviation');
+ if (detection.threat_level) tags.push(`threat-${detection.threat_level}`);
+
+ return {
+ id: String(freq),
+ label: detection.name || category.label,
+ strength: rssiToStrength(detection.rssi),
+ duration: detection.duration || 1000,
+ type: category.type,
+ tags: tags,
+ metadata: {
+ frequency: freq,
+ rssi: detection.rssi,
+ threat_level: detection.threat_level,
+ source: detection.source
+ }
+ };
+ }
+
+ /**
+ * Build tags array from signal data
+ */
+ function buildTags(data, category) {
+ const tags = [];
+
+ if (category.type) tags.push(category.type);
+
+ if (data.modulation) {
+ tags.push(data.modulation.toLowerCase());
+ }
+
+ if (data.is_burst) tags.push('burst');
+ if (data.is_continuous) tags.push('continuous');
+ if (data.is_periodic) tags.push('periodic');
+
+ return tags;
+ }
+
+ /**
+ * Batch normalize multiple signals
+ */
+ function normalizeSignals(signals, type = 'scanner') {
+ const normalizer = type === 'tscm' ? normalizeTscmSignal : normalizeSignal;
+ return signals.map(normalizer);
+ }
+
+ /**
+ * Create timeline configuration for Listening Post mode
+ */
+ function getListeningPostConfig() {
+ return {
+ title: 'Signal Activity',
+ mode: 'listening-post',
+ visualMode: 'enriched',
+ collapsed: false,
+ showAnnotations: true,
+ showLegend: true,
+ defaultWindow: '15m',
+ availableWindows: ['5m', '15m', '30m', '1h'],
+ filters: {
+ hideBaseline: { enabled: true, label: 'Hide Known', default: false },
+ showOnlyNew: { enabled: true, label: 'New Only', default: false },
+ showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
+ },
+ customFilters: [
+ {
+ key: 'hideIsm',
+ label: 'Hide ISM',
+ default: false,
+ predicate: (item) => !item.tags.includes('ism')
+ }
+ ],
+ maxItems: 50,
+ maxDisplayedLanes: 12
+ };
+ }
+
+ /**
+ * Create timeline configuration for TSCM mode
+ */
+ function getTscmConfig() {
+ return {
+ title: 'Signal Activity Timeline',
+ mode: 'tscm',
+ visualMode: 'enriched',
+ collapsed: true,
+ showAnnotations: true,
+ showLegend: true,
+ defaultWindow: '30m',
+ availableWindows: ['5m', '15m', '30m', '1h', '2h'],
+ filters: {
+ hideBaseline: { enabled: true, label: 'Hide Known', default: false },
+ showOnlyNew: { enabled: true, label: 'New Only', default: false },
+ showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
+ },
+ customFilters: [],
+ maxItems: 100,
+ maxDisplayedLanes: 15
+ };
+ }
+
+ // Public API
+ return {
+ // Normalization
+ normalizeSignal: normalizeSignal,
+ normalizeTscmSignal: normalizeTscmSignal,
+ normalizeSignals: normalizeSignals,
+
+ // Utilities
+ rssiToStrength: rssiToStrength,
+ categorizeFrequency: categorizeFrequency,
+
+ // Configuration presets
+ getListeningPostConfig: getListeningPostConfig,
+ getTscmConfig: getTscmConfig,
+
+ // Constants
+ RSSI_THRESHOLDS: RSSI_THRESHOLDS,
+ FREQUENCY_BANDS: FREQUENCY_BANDS
+ };
+})();
+
+// Export for module systems
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = RFTimelineAdapter;
+}
+
+window.RFTimelineAdapter = RFTimelineAdapter;
diff --git a/static/js/components/timeline-adapters/wifi-adapter.js b/static/js/components/timeline-adapters/wifi-adapter.js
new file mode 100644
index 0000000..2acd96d
--- /dev/null
+++ b/static/js/components/timeline-adapters/wifi-adapter.js
@@ -0,0 +1,319 @@
+/**
+ * WiFi Timeline Adapter
+ * Normalizes WiFi network data for the Activity Timeline component
+ * Used by: WiFi mode, TSCM (WiFi detections)
+ */
+
+const WiFiTimelineAdapter = (function() {
+ 'use strict';
+
+ /**
+ * RSSI to strength category mapping for WiFi
+ */
+ const RSSI_THRESHOLDS = {
+ EXCELLENT: -50, // 5 - excellent signal
+ GOOD: -60, // 4 - good signal
+ FAIR: -70, // 3 - fair signal
+ WEAK: -80, // 2 - weak signal
+ POOR: -90 // 1 - very weak
+ };
+
+ /**
+ * WiFi channel to frequency band mapping
+ */
+ const CHANNEL_BANDS = {
+ // 2.4 GHz (channels 1-14)
+ '2.4GHz': { min: 1, max: 14 },
+ // 5 GHz (channels 32-177)
+ '5GHz': { min: 32, max: 177 },
+ // 6 GHz (channels 1-233, WiFi 6E)
+ '6GHz': { min: 1, max: 233, is6e: true }
+ };
+
+ /**
+ * Security type classifications
+ */
+ const SECURITY_TYPES = {
+ OPEN: 'open',
+ WEP: 'wep',
+ WPA: 'wpa',
+ WPA2: 'wpa2',
+ WPA3: 'wpa3',
+ ENTERPRISE: 'enterprise'
+ };
+
+ /**
+ * Convert RSSI to strength category
+ */
+ function rssiToStrength(rssi) {
+ if (rssi === null || rssi === undefined) return 3;
+
+ const r = parseFloat(rssi);
+ if (isNaN(r)) return 3;
+
+ if (r > RSSI_THRESHOLDS.EXCELLENT) return 5;
+ if (r > RSSI_THRESHOLDS.GOOD) return 4;
+ if (r > RSSI_THRESHOLDS.FAIR) return 3;
+ if (r > RSSI_THRESHOLDS.WEAK) return 2;
+ return 1;
+ }
+
+ /**
+ * Determine frequency band from channel
+ */
+ function getBandFromChannel(channel, frequency) {
+ if (frequency) {
+ const f = parseFloat(frequency);
+ if (f >= 5925) return '6GHz';
+ if (f >= 5000) return '5GHz';
+ if (f >= 2400) return '2.4GHz';
+ }
+
+ const ch = parseInt(channel);
+ if (isNaN(ch)) return 'unknown';
+
+ // This is simplified - in practice 6GHz also uses channels 1+
+ // but typically reported with frequency
+ if (ch <= 14) return '2.4GHz';
+ if (ch >= 32 && ch <= 177) return '5GHz';
+
+ return 'unknown';
+ }
+
+ /**
+ * Classify security type
+ */
+ function classifySecurity(network) {
+ const security = (network.security || network.encryption || '').toLowerCase();
+ const auth = (network.auth || '').toLowerCase();
+
+ if (!security || security === 'none' || security === 'open') {
+ return SECURITY_TYPES.OPEN;
+ }
+ if (security.includes('wep')) return SECURITY_TYPES.WEP;
+ if (security.includes('wpa3')) return SECURITY_TYPES.WPA3;
+ if (security.includes('wpa2') || security.includes('rsn')) {
+ if (auth.includes('eap') || auth.includes('802.1x') || auth.includes('enterprise')) {
+ return SECURITY_TYPES.ENTERPRISE;
+ }
+ return SECURITY_TYPES.WPA2;
+ }
+ if (security.includes('wpa')) return SECURITY_TYPES.WPA;
+
+ return 'unknown';
+ }
+
+ /**
+ * Truncate SSID for display
+ */
+ function formatSsid(ssid, maxLength = 20) {
+ if (!ssid) return '[Hidden]';
+ if (ssid.length <= maxLength) return ssid;
+ return ssid.substring(0, maxLength - 3) + '...';
+ }
+
+ /**
+ * Identify potentially interesting network characteristics
+ */
+ function identifyCharacteristics(network) {
+ const characteristics = [];
+ const ssid = (network.ssid || '').toLowerCase();
+
+ // Hidden network
+ if (!network.ssid || network.is_hidden) {
+ characteristics.push('hidden');
+ }
+
+ // Open network
+ if (classifySecurity(network) === SECURITY_TYPES.OPEN) {
+ characteristics.push('open');
+ }
+
+ // Weak security
+ if (classifySecurity(network) === SECURITY_TYPES.WEP) {
+ characteristics.push('weak-security');
+ }
+
+ // Potential hotspot
+ if (/hotspot|mobile|tether|android|iphone/i.test(ssid)) {
+ characteristics.push('hotspot');
+ }
+
+ // Guest network
+ if (/guest|visitor|public/i.test(ssid)) {
+ characteristics.push('guest');
+ }
+
+ // IoT device
+ if (/ring|nest|ecobee|smartthings|wyze|arlo|hue|lifx/i.test(ssid)) {
+ characteristics.push('iot');
+ }
+
+ return characteristics;
+ }
+
+ /**
+ * Normalize a WiFi network detection for the timeline
+ */
+ function normalizeNetwork(network) {
+ const ssid = network.ssid || network.essid || '';
+ const bssid = network.bssid || network.mac || '';
+ const band = getBandFromChannel(network.channel, network.frequency);
+ const security = classifySecurity(network);
+ const characteristics = identifyCharacteristics(network);
+
+ const tags = [band, security, ...characteristics];
+
+ return {
+ id: bssid || ssid,
+ label: formatSsid(ssid) || formatMac(bssid),
+ strength: rssiToStrength(network.rssi || network.signal),
+ duration: network.duration || 1000,
+ type: 'wifi',
+ tags: tags.filter(Boolean),
+ metadata: {
+ ssid: ssid,
+ bssid: bssid,
+ channel: network.channel,
+ frequency: network.frequency,
+ rssi: network.rssi || network.signal,
+ security: security,
+ band: band,
+ characteristics: characteristics
+ }
+ };
+ }
+
+ /**
+ * Normalize for TSCM context
+ */
+ function normalizeTscmNetwork(network) {
+ const normalized = normalizeNetwork(network);
+
+ // Add TSCM-specific tags
+ if (network.is_new) normalized.tags.push('new');
+ if (network.threat_level) normalized.tags.push(`threat-${network.threat_level}`);
+ if (network.is_rogue) normalized.tags.push('rogue');
+ if (network.is_deauth_target) normalized.tags.push('targeted');
+
+ normalized.metadata.threat_level = network.threat_level;
+ normalized.metadata.first_seen = network.first_seen;
+ normalized.metadata.client_count = network.client_count;
+
+ return normalized;
+ }
+
+ /**
+ * Format MAC/BSSID for display
+ */
+ function formatMac(mac) {
+ if (!mac) return 'Unknown';
+ return mac.toUpperCase();
+ }
+
+ /**
+ * Batch normalize multiple networks
+ */
+ function normalizeNetworks(networks, context = 'scan') {
+ const normalizer = context === 'tscm' ? normalizeTscmNetwork : normalizeNetwork;
+ return networks.map(normalizer);
+ }
+
+ /**
+ * Create timeline configuration for WiFi mode
+ */
+ function getWiFiConfig() {
+ return {
+ title: 'Network Activity',
+ mode: 'wifi',
+ visualMode: 'enriched',
+ collapsed: false,
+ showAnnotations: true,
+ showLegend: true,
+ defaultWindow: '15m',
+ availableWindows: ['5m', '15m', '30m', '1h'],
+ filters: {
+ hideBaseline: { enabled: true, label: 'Hide Known', default: false },
+ showOnlyNew: { enabled: true, label: 'New Only', default: false },
+ showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
+ },
+ customFilters: [
+ {
+ key: 'showOnlyOpen',
+ label: 'Open Only',
+ default: false,
+ predicate: (item) => item.tags.includes('open')
+ },
+ {
+ key: 'hideHidden',
+ label: 'Hide Hidden',
+ default: false,
+ predicate: (item) => !item.tags.includes('hidden')
+ },
+ {
+ key: 'show5GHz',
+ label: '5GHz Only',
+ default: false,
+ predicate: (item) => item.tags.includes('5GHz')
+ }
+ ],
+ maxItems: 100,
+ maxDisplayedLanes: 15,
+ labelGenerator: (id) => formatSsid(id)
+ };
+ }
+
+ /**
+ * Create compact configuration for sidebar
+ */
+ function getCompactConfig() {
+ return {
+ title: 'Networks',
+ mode: 'wifi',
+ visualMode: 'compact',
+ collapsed: false,
+ showAnnotations: false,
+ showLegend: false,
+ defaultWindow: '15m',
+ availableWindows: ['5m', '15m', '30m'],
+ filters: {
+ hideBaseline: { enabled: false },
+ showOnlyNew: { enabled: true, label: 'New', default: false },
+ showOnlyBurst: { enabled: false }
+ },
+ customFilters: [],
+ maxItems: 30,
+ maxDisplayedLanes: 8
+ };
+ }
+
+ // Public API
+ return {
+ // Normalization
+ normalizeNetwork: normalizeNetwork,
+ normalizeTscmNetwork: normalizeTscmNetwork,
+ normalizeNetworks: normalizeNetworks,
+
+ // Utilities
+ rssiToStrength: rssiToStrength,
+ getBandFromChannel: getBandFromChannel,
+ classifySecurity: classifySecurity,
+ formatSsid: formatSsid,
+ identifyCharacteristics: identifyCharacteristics,
+
+ // Configuration presets
+ getWiFiConfig: getWiFiConfig,
+ getCompactConfig: getCompactConfig,
+
+ // Constants
+ RSSI_THRESHOLDS: RSSI_THRESHOLDS,
+ SECURITY_TYPES: SECURITY_TYPES
+ };
+})();
+
+// Export for module systems
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = WiFiTimelineAdapter;
+}
+
+window.WiFiTimelineAdapter = WiFiTimelineAdapter;
diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js
index 5f9f588..fb6d37b 100644
--- a/static/js/modes/listening-post.js
+++ b/static/js/modes/listening-post.js
@@ -691,6 +691,25 @@ function addSignalHit(data) {
const hitCount = document.getElementById('scannerHitCount');
if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`;
+
+ // Feed to activity timeline if available
+ if (typeof addTimelineEvent === 'function') {
+ const normalized = typeof RFTimelineAdapter !== 'undefined'
+ ? RFTimelineAdapter.normalizeSignal({
+ frequency: data.frequency,
+ rssi: data.rssi || data.signal_strength,
+ duration: data.duration || 2000,
+ modulation: data.modulation
+ })
+ : {
+ id: String(data.frequency),
+ label: `${data.frequency.toFixed(3)} MHz`,
+ strength: 3,
+ duration: 2000,
+ type: 'rf'
+ };
+ addTimelineEvent('listening', normalized);
+ }
}
function clearScannerLog() {
@@ -700,6 +719,12 @@ function clearScannerLog() {
scannerCycles = 0;
recentSignalHits.clear();
+ // Clear the timeline if available
+ const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null;
+ if (timeline) {
+ timeline.clear();
+ }
+
const signalCount = document.getElementById('scannerSignalCount');
if (signalCount) signalCount.textContent = '0';
diff --git a/templates/index.html b/templates/index.html
index 030fbe4..0374446 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -18,6 +18,7 @@
+
@@ -790,6 +791,10 @@
Waiting for client probe requests...
+
+
@@ -870,6 +875,10 @@
FindMy-compatible devices...
+
+
@@ -1345,6 +1354,11 @@
Ready
+
+
+
@@ -1577,9 +1591,93 @@
+
+
+
+