diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py index be0a7f2..2991a5f 100644 --- a/routes/bluetooth_v2.py +++ b/routes/bluetooth_v2.py @@ -614,9 +614,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: tscm_devices.append({ 'mac': device.address, 'address_type': device.address_type, + 'device_key': device.device_key, 'name': device.name or 'Unknown', 'rssi': device.rssi_current or -100, 'rssi_median': device.rssi_median, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, 'type': _classify_device_type(device), 'manufacturer': device.manufacturer_name, 'protocol': device.protocol, @@ -624,6 +626,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: 'last_seen': device.last_seen.isoformat(), 'seen_count': device.seen_count, 'range_band': device.range_band, + 'proximity_band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + 'distance_confidence': round(device.distance_confidence, 2), + 'is_randomized_mac': device.is_randomized_mac, + 'threat_tags': device.threat_tags, 'heuristics': { 'is_new': device.is_new, 'is_persistent': device.is_persistent, @@ -637,6 +644,171 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: return tscm_devices +# ============================================================================= +# PROXIMITY & HEATMAP ENDPOINTS +# ============================================================================= + + +@bluetooth_v2_bp.route('/proximity/snapshot', methods=['GET']) +def get_proximity_snapshot(): + """ + Get proximity snapshot for radar visualization. + + All active devices with proximity data including estimated distance, + proximity band, and confidence scores. + + Query parameters: + - max_age: Maximum age in seconds (default: 60) + - min_confidence: Minimum distance confidence (default: 0) + + Returns: + JSON with proximity data for all active devices. + """ + scanner = get_bluetooth_scanner() + max_age = request.args.get('max_age', 60, type=float) + min_confidence = request.args.get('min_confidence', 0.0, type=float) + + devices = scanner.get_devices(max_age_seconds=max_age) + + # Filter by confidence if specified + if min_confidence > 0: + devices = [d for d in devices if d.distance_confidence >= min_confidence] + + # Build proximity snapshot + snapshot = { + 'timestamp': datetime.now().isoformat(), + 'device_count': len(devices), + 'zone_counts': { + 'immediate': 0, + 'near': 0, + 'far': 0, + 'unknown': 0, + }, + 'devices': [], + } + + for device in devices: + # Count by zone + band = device.proximity_band or 'unknown' + if band in snapshot['zone_counts']: + snapshot['zone_counts'][band] += 1 + else: + snapshot['zone_counts']['unknown'] += 1 + + snapshot['devices'].append({ + 'device_key': device.device_key, + 'device_id': device.device_id, + 'name': device.name, + 'address': device.address, + 'rssi_current': device.rssi_current, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + 'proximity_band': device.proximity_band, + 'distance_confidence': round(device.distance_confidence, 2), + 'is_new': device.is_new, + 'is_randomized_mac': device.is_randomized_mac, + 'in_baseline': device.in_baseline, + 'heuristic_flags': device.heuristic_flags, + 'last_seen': device.last_seen.isoformat(), + 'age_seconds': round(device.age_seconds, 1), + }) + + return jsonify(snapshot) + + +@bluetooth_v2_bp.route('/heatmap/data', methods=['GET']) +def get_heatmap_data(): + """ + Get heatmap data for timeline visualization. + + Returns top N devices with downsampled RSSI timeseries. + + Query parameters: + - top_n: Number of devices (default: 20) + - window_minutes: Time window (default: 10) + - bucket_seconds: Bucket size for downsampling (default: 10) + - sort_by: Sort method - 'recency', 'strength', 'activity' (default: 'recency') + + Returns: + JSON with device timeseries data for heatmap. + """ + scanner = get_bluetooth_scanner() + + top_n = request.args.get('top_n', 20, type=int) + window_minutes = request.args.get('window_minutes', 10, type=int) + bucket_seconds = request.args.get('bucket_seconds', 10, type=int) + sort_by = request.args.get('sort_by', 'recency') + + # Validate sort_by + if sort_by not in ('recency', 'strength', 'activity'): + sort_by = 'recency' + + # Get heatmap data from aggregator + heatmap_data = scanner._aggregator.get_heatmap_data( + top_n=top_n, + window_minutes=window_minutes, + bucket_seconds=bucket_seconds, + sort_by=sort_by, + ) + + return jsonify(heatmap_data) + + +@bluetooth_v2_bp.route('/devices//timeseries', methods=['GET']) +def get_device_timeseries(device_key: str): + """ + Get timeseries data for a specific device. + + Path parameters: + - device_key: Stable device identifier + + Query parameters: + - window_minutes: Time window (default: 30) + - bucket_seconds: Bucket size for downsampling (default: 10) + + Returns: + JSON with device timeseries data. + """ + scanner = get_bluetooth_scanner() + + window_minutes = request.args.get('window_minutes', 30, type=int) + bucket_seconds = request.args.get('bucket_seconds', 10, type=int) + + # URL decode device key + from urllib.parse import unquote + device_key = unquote(device_key) + + # Get device info + device = scanner._aggregator.get_device_by_key(device_key) + + # Get timeseries data + timeseries = scanner._aggregator.get_timeseries( + device_key=device_key, + window_minutes=window_minutes, + downsample_seconds=bucket_seconds, + ) + + result = { + 'device_key': device_key, + 'window_minutes': window_minutes, + 'bucket_seconds': bucket_seconds, + 'observation_count': len(timeseries), + 'timeseries': timeseries, + } + + if device: + result.update({ + 'name': device.name, + 'address': device.address, + 'rssi_current': device.rssi_current, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'proximity_band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + }) + + return jsonify(result) + + def _classify_device_type(device: BTDeviceAggregate) -> str: """Classify device type from available data.""" name_lower = (device.name or '').lower() diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css new file mode 100644 index 0000000..a99a8b5 --- /dev/null +++ b/static/css/components/proximity-viz.css @@ -0,0 +1,287 @@ +/** + * Proximity Visualization Components + * Styles for radar and timeline heatmap + */ + +/* ============================================ + PROXIMITY RADAR + ============================================ */ + +.proximity-radar-svg { + display: block; + margin: 0 auto; +} + +.radar-device { + transition: transform 0.2s ease; +} + +.radar-device:hover { + transform: scale(1.3); +} + +.radar-dot-pulse circle:first-child { + animation: radar-pulse 1.5s ease-out infinite; +} + +@keyframes radar-pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +.radar-sweep { + transform-origin: 50% 50%; +} + +/* Radar filter buttons */ +.bt-radar-filter-btn { + transition: all 0.2s ease; +} + +.bt-radar-filter-btn:hover { + background: var(--bg-hover, #333) !important; + color: #fff !important; +} + +.bt-radar-filter-btn.active { + background: #00d4ff !important; + color: #000 !important; + border-color: #00d4ff !important; +} + +#btRadarPauseBtn.active { + background: #f97316 !important; + color: #000 !important; + border-color: #f97316 !important; +} + +/* ============================================ + TIMELINE HEATMAP + ============================================ */ + +.timeline-heatmap-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 8px 0; + margin-bottom: 8px; + border-bottom: 1px solid var(--border-color, #333); +} + +.heatmap-control-group { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-dim, #888); +} + +.heatmap-select { + background: var(--bg-tertiary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-primary, #e0e0e0); + font-size: 10px; + padding: 4px 8px; + cursor: pointer; +} + +.heatmap-select:hover { + border-color: var(--accent-color, #00d4ff); +} + +.heatmap-btn { + background: var(--bg-tertiary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-dim, #888); + font-size: 10px; + padding: 4px 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.heatmap-btn:hover { + background: var(--bg-hover, #252525); + color: var(--text-primary, #e0e0e0); +} + +.heatmap-btn.active { + background: #f97316; + color: #000; + border-color: #f97316; +} + +.timeline-heatmap-content { + max-height: 300px; + overflow-y: auto; + overflow-x: auto; +} + +.heatmap-loading, +.heatmap-empty, +.heatmap-error { + color: var(--text-dim, #666); + text-align: center; + padding: 30px; + font-size: 12px; +} + +.heatmap-error { + color: #ef4444; +} + +.heatmap-grid { + display: flex; + flex-direction: column; + gap: 2px; + min-width: max-content; +} + +.heatmap-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + cursor: pointer; + border-radius: 4px; + transition: background 0.2s ease; +} + +.heatmap-row:hover:not(.heatmap-header) { + background: rgba(255, 255, 255, 0.05); +} + +.heatmap-row.selected { + background: rgba(0, 212, 255, 0.1); + outline: 1px solid rgba(0, 212, 255, 0.3); +} + +.heatmap-header { + cursor: default; + border-bottom: 1px solid var(--border-color, #333); + margin-bottom: 4px; +} + +.heatmap-label { + width: 120px; + min-width: 120px; + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 8px; + overflow: hidden; +} + +.heatmap-label .device-name { + font-size: 10px; + color: var(--text-primary, #e0e0e0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.heatmap-label .device-rssi { + font-size: 9px; + color: var(--text-dim, #666); + font-family: monospace; +} + +.heatmap-cells { + display: flex; + gap: 1px; +} + +.heatmap-cell { + border-radius: 2px; + transition: transform 0.1s ease; +} + +.heatmap-cell:hover { + transform: scale(1.5); + z-index: 10; + position: relative; +} + +.heatmap-time-label { + font-size: 8px; + color: var(--text-dim, #666); + text-align: center; + transform: rotate(-45deg); + white-space: nowrap; +} + +.heatmap-legend { + display: flex; + align-items: center; + gap: 12px; + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid var(--border-color, #333); + font-size: 10px; + color: var(--text-dim, #666); +} + +.legend-label { + font-weight: 500; +} + +.legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 2px; +} + +/* ============================================ + ZONE SUMMARY + ============================================ */ + +#btZoneSummary { + padding: 8px 0; +} + +#btZoneSummary > div { + min-width: 60px; +} + +/* ============================================ + RESPONSIVE ADJUSTMENTS + ============================================ */ + +@media (max-width: 768px) { + .timeline-heatmap-controls { + flex-direction: column; + align-items: stretch; + } + + .heatmap-control-group { + justify-content: space-between; + } + + .proximity-radar-svg { + max-width: 100%; + height: auto; + } + + #btRadarControls { + flex-direction: column; + gap: 4px; + } + + #btZoneSummary { + flex-wrap: wrap; + } +} diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js new file mode 100644 index 0000000..41a774c --- /dev/null +++ b/static/js/components/proximity-radar.js @@ -0,0 +1,369 @@ +/** + * Proximity Radar Component + * + * SVG-based circular radar visualization for Bluetooth device proximity. + * Displays devices positioned by estimated distance with concentric rings + * for proximity bands. + */ + +const ProximityRadar = (function() { + 'use strict'; + + // Configuration + const CONFIG = { + size: 280, + padding: 20, + centerRadius: 8, + rings: [ + { band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' }, + { band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' }, + { band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' }, + ], + dotMinSize: 4, + dotMaxSize: 12, + pulseAnimationDuration: 2000, + newDeviceThreshold: 30, // seconds + }; + + // State + let container = null; + let svg = null; + let devices = new Map(); + let isPaused = false; + let activeFilter = null; + let onDeviceClick = null; + + /** + * Initialize the radar component + */ + function init(containerId, options = {}) { + container = document.getElementById(containerId); + if (!container) { + console.error('[ProximityRadar] Container not found:', containerId); + return; + } + + if (options.onDeviceClick) { + onDeviceClick = options.onDeviceClick; + } + + createSVG(); + } + + /** + * Create the SVG radar structure + */ + function createSVG() { + const size = CONFIG.size; + const center = size / 2; + + container.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + ${CONFIG.rings.map((ring, i) => { + const r = ring.radius * (center - CONFIG.padding); + return ` + + ${ring.label} + `; + }).join('')} + + + + + + + + + + + + + + PROXIMITY + (signal strength) + + + `; + + svg = container.querySelector('svg'); + + // Add sweep animation + animateSweep(); + } + + /** + * Animate the radar sweep line + */ + function animateSweep() { + const sweepLine = svg.querySelector('.radar-sweep'); + if (!sweepLine) return; + + let angle = 0; + const center = CONFIG.size / 2; + + function rotate() { + if (isPaused) { + requestAnimationFrame(rotate); + return; + } + + angle = (angle + 1) % 360; + const rad = (angle * Math.PI) / 180; + const radius = center - CONFIG.padding; + const x2 = center + Math.sin(rad) * radius; + const y2 = center - Math.cos(rad) * radius; + + sweepLine.setAttribute('x2', x2); + sweepLine.setAttribute('y2', y2); + + requestAnimationFrame(rotate); + } + + requestAnimationFrame(rotate); + } + + /** + * Update devices on the radar + */ + function updateDevices(deviceList) { + if (isPaused) return; + + // Update device map + deviceList.forEach(device => { + devices.set(device.device_key, device); + }); + + // Apply filter and render + renderDevices(); + } + + /** + * Render device dots on the radar + */ + function renderDevices() { + const devicesGroup = svg.querySelector('.radar-devices'); + if (!devicesGroup) return; + + const center = CONFIG.size / 2; + const maxRadius = center - CONFIG.padding; + + // Filter devices + let visibleDevices = Array.from(devices.values()); + + if (activeFilter === 'newOnly') { + visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold); + } else if (activeFilter === 'strongest') { + visibleDevices = visibleDevices + .filter(d => d.rssi_current != null) + .sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100)) + .slice(0, 10); + } else if (activeFilter === 'unapproved') { + visibleDevices = visibleDevices.filter(d => !d.in_baseline); + } + + // Build SVG for each device + const dots = visibleDevices.map(device => { + // Calculate position + const { x, y, radius } = calculateDevicePosition(device, center, maxRadius); + + // Calculate dot size based on confidence + const confidence = device.distance_confidence || 0.5; + const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; + + // Get color based on proximity band + const color = getBandColor(device.proximity_band); + + // Check if newly seen (pulse animation) + const isNew = device.age_seconds < 5; + const pulseClass = isNew ? 'radar-dot-pulse' : ''; + + return ` + + + ${device.is_new ? `` : ''} + ${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm) + + `; + }).join(''); + + devicesGroup.innerHTML = dots; + + // Attach click handlers + devicesGroup.querySelectorAll('.radar-device').forEach(el => { + el.addEventListener('click', (e) => { + const deviceKey = el.getAttribute('data-device-key'); + if (onDeviceClick && deviceKey) { + onDeviceClick(deviceKey); + } + }); + }); + } + + /** + * Calculate device position on radar + */ + function calculateDevicePosition(device, center, maxRadius) { + // Calculate radius based on proximity band/distance + let radiusRatio; + const band = device.proximity_band || 'unknown'; + + if (device.estimated_distance_m != null) { + // Use actual distance (log scale) + const maxDistance = 15; + radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1)); + } else { + // Use band-based positioning + switch (band) { + case 'immediate': radiusRatio = 0.15; break; + case 'near': radiusRatio = 0.4; break; + case 'far': radiusRatio = 0.7; break; + default: radiusRatio = 0.9; break; + } + } + + // Calculate angle based on device key hash (stable positioning) + const angle = hashToAngle(device.device_key || device.device_id); + const radius = radiusRatio * maxRadius; + + const x = center + Math.sin(angle) * radius; + const y = center - Math.cos(angle) * radius; + + return { x, y, radius }; + } + + /** + * Hash string to angle for stable positioning + */ + function hashToAngle(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash = hash & hash; + } + return (Math.abs(hash) % 360) * (Math.PI / 180); + } + + /** + * Get color for proximity band + */ + function getBandColor(band) { + switch (band) { + case 'immediate': return '#22c55e'; + case 'near': return '#eab308'; + case 'far': return '#ef4444'; + default: return '#6b7280'; + } + } + + /** + * Set filter mode + */ + function setFilter(filter) { + activeFilter = filter === activeFilter ? null : filter; + renderDevices(); + } + + /** + * Toggle pause state + */ + function setPaused(paused) { + isPaused = paused; + } + + /** + * Clear all devices + */ + function clear() { + devices.clear(); + renderDevices(); + } + + /** + * Get zone counts + */ + function getZoneCounts() { + const counts = { immediate: 0, near: 0, far: 0, unknown: 0 }; + devices.forEach(device => { + const band = device.proximity_band || 'unknown'; + if (counts.hasOwnProperty(band)) { + counts[band]++; + } else { + counts.unknown++; + } + }); + return counts; + } + + /** + * Escape HTML for safe rendering + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + /** + * Escape attribute value + */ + function escapeAttr(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + // Public API + return { + init, + updateDevices, + setFilter, + setPaused, + clear, + getZoneCounts, + isPaused: () => isPaused, + getFilter: () => activeFilter, + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = ProximityRadar; +} + +window.ProximityRadar = ProximityRadar; diff --git a/static/js/components/timeline-heatmap.js b/static/js/components/timeline-heatmap.js new file mode 100644 index 0000000..b7d4a3e --- /dev/null +++ b/static/js/components/timeline-heatmap.js @@ -0,0 +1,409 @@ +/** + * Timeline Heatmap Component + * + * Displays RSSI signal history as a heatmap grid. + * Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength + */ + +const TimelineHeatmap = (function() { + 'use strict'; + + // Configuration + const CONFIG = { + cellWidth: 8, + cellHeight: 20, + labelWidth: 120, + maxDevices: 20, + refreshInterval: 5000, + // RSSI color scale (green = strong, red = weak) + colorScale: [ + { rssi: -40, color: '#22c55e' }, // Strong - green + { rssi: -55, color: '#84cc16' }, // Good - lime + { rssi: -65, color: '#eab308' }, // Medium - yellow + { rssi: -75, color: '#f97316' }, // Weak - orange + { rssi: -90, color: '#ef4444' }, // Very weak - red + ], + noDataColor: '#2a2a3e', + }; + + // State + let container = null; + let contentEl = null; + let controlsEl = null; + let data = null; + let isPaused = false; + let refreshTimer = null; + let selectedDeviceKey = null; + let onDeviceSelect = null; + + // Settings + let settings = { + windowMinutes: 10, + bucketSeconds: 10, + sortBy: 'recency', + topN: 20, + }; + + /** + * Initialize the heatmap component + */ + function init(containerId, options = {}) { + container = document.getElementById(containerId); + if (!container) { + console.error('[TimelineHeatmap] Container not found:', containerId); + return; + } + + if (options.onDeviceSelect) { + onDeviceSelect = options.onDeviceSelect; + } + + // Merge options into settings + Object.assign(settings, options); + + createStructure(); + startAutoRefresh(); + } + + /** + * Create the heatmap DOM structure + */ + function createStructure() { + container.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
Loading signal history...
+
+
+ Signal: + Strong + Medium + Weak + No data +
+ `; + + contentEl = container.querySelector('.timeline-heatmap-content'); + controlsEl = container.querySelector('.timeline-heatmap-controls'); + + // Attach event listeners + attachEventListeners(); + } + + /** + * Attach event listeners to controls + */ + function attachEventListeners() { + const windowSelect = container.querySelector('#heatmapWindow'); + const bucketSelect = container.querySelector('#heatmapBucket'); + const sortSelect = container.querySelector('#heatmapSort'); + const pauseBtn = container.querySelector('#heatmapPauseBtn'); + + windowSelect?.addEventListener('change', (e) => { + settings.windowMinutes = parseInt(e.target.value, 10); + refresh(); + }); + + bucketSelect?.addEventListener('change', (e) => { + settings.bucketSeconds = parseInt(e.target.value, 10); + refresh(); + }); + + sortSelect?.addEventListener('change', (e) => { + settings.sortBy = e.target.value; + refresh(); + }); + + pauseBtn?.addEventListener('click', () => { + isPaused = !isPaused; + pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', isPaused); + }); + } + + /** + * Start auto-refresh timer + */ + function startAutoRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + + refreshTimer = setInterval(() => { + if (!isPaused) { + refresh(); + } + }, CONFIG.refreshInterval); + } + + /** + * Fetch and render heatmap data + */ + async function refresh() { + if (!container) return; + + try { + const params = new URLSearchParams({ + top_n: settings.topN, + window_minutes: settings.windowMinutes, + bucket_seconds: settings.bucketSeconds, + sort_by: settings.sortBy, + }); + + const response = await fetch(`/api/bluetooth/heatmap/data?${params}`); + if (!response.ok) throw new Error('Failed to fetch heatmap data'); + + data = await response.json(); + render(); + } catch (err) { + console.error('[TimelineHeatmap] Refresh error:', err); + contentEl.innerHTML = '
Failed to load data
'; + } + } + + /** + * Render the heatmap grid + */ + function render() { + if (!data || !data.devices || data.devices.length === 0) { + contentEl.innerHTML = '
No signal history available yet
'; + return; + } + + // Calculate time buckets + const windowMs = settings.windowMinutes * 60 * 1000; + const bucketMs = settings.bucketSeconds * 1000; + const numBuckets = Math.ceil(windowMs / bucketMs); + const now = new Date(); + + // Generate time labels + const timeLabels = []; + for (let i = 0; i < numBuckets; i++) { + const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs); + if (i % Math.ceil(numBuckets / 6) === 0) { + timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } else { + timeLabels.push(''); + } + } + + // Build heatmap HTML + let html = '
'; + + // Time axis header + html += `
+
+
+ ${timeLabels.map(label => + `
${label}
` + ).join('')} +
+
`; + + // Device rows + data.devices.forEach(device => { + const isSelected = device.device_key === selectedDeviceKey; + const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row'; + + // Create lookup for timeseries data + const tsLookup = new Map(); + device.timeseries.forEach(point => { + const ts = new Date(point.timestamp).getTime(); + tsLookup.set(ts, point.rssi); + }); + + // Generate cells for each time bucket + const cells = []; + for (let i = 0; i < numBuckets; i++) { + const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs); + const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs; + + // Find closest timestamp in data + let rssi = null; + const tolerance = bucketMs; + tsLookup.forEach((val, ts) => { + if (Math.abs(ts - bucketKey) < tolerance) { + rssi = val; + } + }); + + const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor; + const title = rssi !== null ? `${rssi} dBm` : 'No data'; + + cells.push(`
`); + } + + const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12); + const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--'; + + html += ` +
+
+ ${escapeHtml(displayName)} + ${rssiDisplay} +
+
${cells.join('')}
+
+ `; + }); + + html += '
'; + contentEl.innerHTML = html; + + // Attach row click handlers + contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => { + row.addEventListener('click', () => { + const deviceKey = row.getAttribute('data-device-key'); + selectDevice(deviceKey); + }); + }); + } + + /** + * Get color for RSSI value + */ + function getRssiColor(rssi) { + const scale = CONFIG.colorScale; + + // Find the appropriate color from scale + for (let i = 0; i < scale.length; i++) { + if (rssi >= scale[i].rssi) { + return scale[i].color; + } + } + return scale[scale.length - 1].color; + } + + /** + * Format MAC address for display + */ + function formatAddress(address) { + if (!address) return null; + const parts = address.split(':'); + if (parts.length === 6) { + return `${parts[0]}:${parts[1]}:..${parts[5]}`; + } + return address; + } + + /** + * Select a device row + */ + function selectDevice(deviceKey) { + selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey; + + // Update row highlighting + contentEl.querySelectorAll('.heatmap-row').forEach(row => { + const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey; + row.classList.toggle('selected', isSelected); + }); + + // Callback + if (onDeviceSelect && selectedDeviceKey) { + const device = data?.devices?.find(d => d.device_key === selectedDeviceKey); + onDeviceSelect(selectedDeviceKey, device); + } + } + + /** + * Update with new data directly (for SSE integration) + */ + function updateData(newData) { + if (isPaused) return; + data = newData; + render(); + } + + /** + * Set paused state + */ + function setPaused(paused) { + isPaused = paused; + const pauseBtn = container?.querySelector('#heatmapPauseBtn'); + if (pauseBtn) { + pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', isPaused); + } + } + + /** + * Destroy the component + */ + function destroy() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + if (container) { + container.innerHTML = ''; + } + } + + /** + * Escape HTML for safe rendering + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + /** + * Escape attribute value + */ + function escapeAttr(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + // Public API + return { + init, + refresh, + updateData, + setPaused, + destroy, + selectDevice, + getSelectedDevice: () => selectedDeviceKey, + isPaused: () => isPaused, + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = TimelineHeatmap; +} + +window.TimelineHeatmap = TimelineHeatmap; diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index b0c6b42..7771385 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -35,6 +35,11 @@ const BluetoothMode = (function() { // Zone counts for proximity display let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 }; + // New visualization components + let radarInitialized = false; + let heatmapInitialized = false; + let radarPaused = false; + /** * Initialize the Bluetooth mode */ @@ -60,7 +65,11 @@ const BluetoothMode = (function() { // Check scan status (in case page was reloaded during scan) checkScanStatus(); - // Initialize proximity visualization + // Initialize proximity visualization (new radar + heatmap) + initProximityRadar(); + initTimelineHeatmap(); + + // Initialize legacy heatmap (zone counts) initHeatmap(); // Initialize timeline as collapsed @@ -70,6 +79,134 @@ const BluetoothMode = (function() { updateVisualizationPanels(); } + /** + * Initialize the new proximity radar component + */ + function initProximityRadar() { + const radarContainer = document.getElementById('btProximityRadar'); + if (!radarContainer) return; + + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('btProximityRadar', { + onDeviceClick: (deviceKey) => { + // Find device by key and show modal + const device = Array.from(devices.values()).find(d => d.device_key === deviceKey); + if (device) { + selectDevice(device.device_id); + } + } + }); + radarInitialized = true; + + // Setup radar controls + setupRadarControls(); + } + } + + /** + * Setup radar control button handlers + */ + function setupRadarControls() { + // Filter buttons + document.querySelectorAll('#btRadarControls button[data-filter]').forEach(btn => { + btn.addEventListener('click', () => { + const filter = btn.getAttribute('data-filter'); + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.setFilter(filter); + + // Update button states + document.querySelectorAll('#btRadarControls button[data-filter]').forEach(b => { + b.classList.remove('active'); + }); + if (ProximityRadar.getFilter() === filter) { + btn.classList.add('active'); + } + } + }); + }); + + // Pause button + const pauseBtn = document.getElementById('btRadarPauseBtn'); + if (pauseBtn) { + pauseBtn.addEventListener('click', () => { + radarPaused = !radarPaused; + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.setPaused(radarPaused); + } + pauseBtn.textContent = radarPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', radarPaused); + }); + } + } + + /** + * Initialize the timeline heatmap component + */ + function initTimelineHeatmap() { + const heatmapContainer = document.getElementById('btTimelineHeatmap'); + if (!heatmapContainer) return; + + if (typeof TimelineHeatmap !== 'undefined') { + TimelineHeatmap.init('btTimelineHeatmap', { + windowMinutes: 10, + bucketSeconds: 10, + sortBy: 'recency', + onDeviceSelect: (deviceKey, device) => { + // Find device and show modal + const fullDevice = Array.from(devices.values()).find(d => d.device_key === deviceKey); + if (fullDevice) { + selectDevice(fullDevice.device_id); + } + } + }); + heatmapInitialized = true; + } + } + + /** + * Update the proximity radar with current devices + */ + function updateRadar() { + if (!radarInitialized || typeof ProximityRadar === 'undefined') return; + + // Convert devices map to array for radar + const deviceList = Array.from(devices.values()).map(d => ({ + device_key: d.device_key || d.device_id, + device_id: d.device_id, + name: d.name, + address: d.address, + rssi_current: d.rssi_current, + rssi_ema: d.rssi_ema, + estimated_distance_m: d.estimated_distance_m, + proximity_band: d.proximity_band || 'unknown', + distance_confidence: d.distance_confidence || 0.5, + is_new: d.is_new || !d.in_baseline, + is_randomized_mac: d.is_randomized_mac, + in_baseline: d.in_baseline, + heuristic_flags: d.heuristic_flags || [], + age_seconds: d.age_seconds || 0, + })); + + ProximityRadar.updateDevices(deviceList); + + // Update zone counts from radar + const counts = ProximityRadar.getZoneCounts(); + updateProximityZoneCounts(counts); + } + + /** + * Update proximity zone counts display (new system) + */ + function updateProximityZoneCounts(counts) { + const immediateEl = document.getElementById('btZoneImmediate'); + const nearEl = document.getElementById('btZoneNear'); + const farEl = document.getElementById('btZoneFar'); + + if (immediateEl) immediateEl.textContent = counts.immediate || 0; + if (nearEl) nearEl.textContent = counts.near || 0; + if (farEl) farEl.textContent = counts.far || 0; + } + /** * Initialize proximity zones display */ @@ -485,6 +622,11 @@ const BluetoothMode = (function() { }; updateVisualizationPanels(); updateProximityZones(); + + // Clear radar + if (radarInitialized && typeof ProximityRadar !== 'undefined') { + ProximityRadar.clear(); + } } function startEventStream() { @@ -529,6 +671,9 @@ const BluetoothMode = (function() { updateVisualizationPanels(); updateProximityZones(); + // Update new proximity radar + updateRadar(); + // Feed to activity timeline addToTimeline(device); } diff --git a/templates/index.html b/templates/index.html index 1f93cc5..62335b6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,6 +20,7 @@ + @@ -706,29 +707,28 @@