/** * 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 }; // Configuration const POSITION_EMA_ALPHA = 0.25; // lower = more smoothing (0.25 → ~4 updates to reach 68% of a step) // State let container = null; let svg = null; let devices = new Map(); let positionCache = new Map(); // device_key → { x, y } smoothed position let isPaused = false; let activeFilter = null; let onDeviceClick = null; let selectedDeviceKey = null; let renderTimer = 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'); // Event delegation on the devices group (survives innerHTML rebuilds) const devicesGroup = svg.querySelector('.radar-devices'); devicesGroup.addEventListener('click', (e) => { const deviceEl = e.target.closest('.radar-device'); if (!deviceEl) return; const deviceKey = deviceEl.getAttribute('data-device-key'); if (onDeviceClick && deviceKey) { onDeviceClick(deviceKey); } }); // 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; deviceList.forEach(device => { devices.set(device.device_key, device); }); // Debounce rapid updates (e.g. per-device SSE events) if (renderTimer) clearTimeout(renderTimer); renderTimer = setTimeout(() => { renderTimer = null; renderDevices(); }, 200); } /** * Render device dots on the radar using in-place DOM updates. * Elements are never destroyed and recreated — only their attributes and * transforms are mutated — so hover state is never disturbed by a render. */ function renderDevices() { const devicesGroup = svg.querySelector('.radar-devices'); if (!devicesGroup) return; const center = CONFIG.size / 2; const maxRadius = center - CONFIG.padding; const ns = 'http://www.w3.org/2000/svg'; // 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); } const visibleKeys = new Set(visibleDevices.map(d => d.device_key)); // Remove elements for devices no longer in the visible set devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { const k = el.getAttribute('data-device-key'); if (!visibleKeys.has(k)) { positionCache.delete(k); el.remove(); } }); visibleDevices.forEach(device => { // Raw target position from distance/band const { x: rawX, y: rawY } = calculateDevicePosition(device, center, maxRadius); // EMA smoothing: blend towards the new position rather than snapping, // so RSSI noise doesn't translate 1:1 into visible movement. const cached = positionCache.get(device.device_key); const x = cached ? cached.x * (1 - POSITION_EMA_ALPHA) + rawX * POSITION_EMA_ALPHA : rawX; const y = cached ? cached.y * (1 - POSITION_EMA_ALPHA) + rawY * POSITION_EMA_ALPHA : rawY; positionCache.set(device.device_key, { x, y }); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const color = getBandColor(device.proximity_band); const isNew = device.age_seconds < 5; const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); const hitAreaSize = Math.max(dotSize * 2, 15); const key = device.device_key; const existing = devicesGroup.querySelector( `.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]` ); if (existing) { // ── In-place update: mutate attributes, never recreate ── existing.style.transform = `translate(${x}px, ${y}px)`; const innerG = existing.querySelector('.radar-device'); if (innerG) { innerG.className.baseVal = `radar-device${isNew ? ' radar-dot-pulse' : ''}${isSelected ? ' selected' : ''}`; const hitArea = innerG.querySelector('.radar-device-hitarea'); if (hitArea) hitArea.setAttribute('r', hitAreaSize); const dot = innerG.querySelector('.radar-dot'); if (dot) { dot.setAttribute('r', dotSize); dot.setAttribute('fill', color); dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); dot.setAttribute('stroke-width', isSelected ? 2 : 1); } const title = innerG.querySelector('title'); if (title) { title.textContent = `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; } // Selection ring: add if newly selected, remove if deselected let ring = innerG.querySelector('.radar-select-ring'); if (isSelected && !ring) { ring = buildSelectRing(ns, dotSize); const hitAreaEl = innerG.querySelector('.radar-device-hitarea'); innerG.insertBefore(ring, hitAreaEl ? hitAreaEl.nextSibling : innerG.firstChild); } else if (!isSelected && ring) { ring.remove(); } // New-device indicator ring let newRing = innerG.querySelector('.radar-new-ring'); if (device.is_new && !isSelected) { if (!newRing) { newRing = document.createElementNS(ns, 'circle'); newRing.classList.add('radar-new-ring'); newRing.setAttribute('fill', 'none'); newRing.setAttribute('stroke', '#3b82f6'); newRing.setAttribute('stroke-width', '1'); newRing.setAttribute('stroke-dasharray', '2,2'); innerG.appendChild(newRing); } newRing.setAttribute('r', dotSize + 3); } else if (newRing) { newRing.remove(); } } } else { // ── Create new element ── const wrapperG = document.createElementNS(ns, 'g'); wrapperG.classList.add('radar-device-wrapper'); wrapperG.setAttribute('data-device-key', key); wrapperG.style.transform = `translate(${x}px, ${y}px)`; wrapperG.style.transition = 'transform 0.6s ease-out'; const innerG = document.createElementNS(ns, 'g'); innerG.classList.add('radar-device'); if (isNew) innerG.classList.add('radar-dot-pulse'); if (isSelected) innerG.classList.add('selected'); innerG.setAttribute('data-device-key', escapeAttr(key)); innerG.style.cursor = 'pointer'; const hitArea = document.createElementNS(ns, 'circle'); hitArea.classList.add('radar-device-hitarea'); hitArea.setAttribute('r', hitAreaSize); hitArea.setAttribute('fill', 'transparent'); innerG.appendChild(hitArea); if (isSelected) { innerG.appendChild(buildSelectRing(ns, dotSize)); } const dot = document.createElementNS(ns, 'circle'); dot.classList.add('radar-dot'); dot.setAttribute('r', dotSize); dot.setAttribute('fill', color); dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); dot.setAttribute('stroke-width', isSelected ? 2 : 1); innerG.appendChild(dot); if (device.is_new && !isSelected) { const newRing = document.createElementNS(ns, 'circle'); newRing.classList.add('radar-new-ring'); newRing.setAttribute('r', dotSize + 3); newRing.setAttribute('fill', 'none'); newRing.setAttribute('stroke', '#3b82f6'); newRing.setAttribute('stroke-width', '1'); newRing.setAttribute('stroke-dasharray', '2,2'); innerG.appendChild(newRing); } const title = document.createElementNS(ns, 'title'); title.textContent = `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; innerG.appendChild(title); wrapperG.appendChild(innerG); devicesGroup.appendChild(wrapperG); } }); } /** * Build an animated SVG selection ring element */ function buildSelectRing(ns, dotSize) { const ring = document.createElementNS(ns, 'circle'); ring.classList.add('radar-select-ring'); ring.setAttribute('r', dotSize + 8); ring.setAttribute('fill', 'none'); ring.setAttribute('stroke', '#00d4ff'); ring.setAttribute('stroke-width', '2'); ring.setAttribute('stroke-opacity', '0.8'); const animR = document.createElementNS(ns, 'animate'); animR.setAttribute('attributeName', 'r'); animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`); animR.setAttribute('dur', '1.5s'); animR.setAttribute('repeatCount', 'indefinite'); ring.appendChild(animR); const animO = document.createElementNS(ns, 'animate'); animO.setAttribute('attributeName', 'stroke-opacity'); animO.setAttribute('values', '0.8;0.4;0.8'); animO.setAttribute('dur', '1.5s'); animO.setAttribute('repeatCount', 'indefinite'); ring.appendChild(animO); return ring; } /** * 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(); positionCache.clear(); selectedDeviceKey = null; renderDevices(); } /** * Highlight a specific device on the radar (in-place update, no full re-render) */ function highlightDevice(deviceKey) { const prev = selectedDeviceKey; selectedDeviceKey = deviceKey; if (!svg) { return; } const devicesGroup = svg.querySelector('.radar-devices'); if (!devicesGroup) { return; } // Remove highlight from previously selected node if (prev && prev !== deviceKey) { const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`); if (oldEl) { oldEl.classList.remove('selected'); // Remove animated selection ring const ring = oldEl.querySelector('.radar-select-ring'); if (ring) ring.remove(); // Restore dot opacity const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { const device = devices.get(prev); const confidence = device ? (device.distance_confidence || 0.5) : 0.5; dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5); dot.setAttribute('stroke', dot.getAttribute('fill')); dot.setAttribute('stroke-width', '1'); } } } // Add highlight to newly selected node if (deviceKey) { const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`); if (newEl) { applySelectionToElement(newEl, deviceKey); } else { // Node not in DOM yet; full render needed on next cycle renderDevices(); } } } /** * Apply selection styling to a radar device element in-place */ function applySelectionToElement(el, deviceKey) { el.classList.add('selected'); const device = devices.get(deviceKey); const confidence = device ? (device.distance_confidence || 0.5) : 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; // Update dot styling const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { dot.setAttribute('fill-opacity', '1'); dot.setAttribute('stroke', '#00d4ff'); dot.setAttribute('stroke-width', '2'); } // Add animated selection ring if not already present if (!el.querySelector('.radar-select-ring')) { const ns = 'http://www.w3.org/2000/svg'; const ring = document.createElementNS(ns, 'circle'); ring.classList.add('radar-select-ring'); ring.setAttribute('r', dotSize + 8); ring.setAttribute('fill', 'none'); ring.setAttribute('stroke', '#00d4ff'); ring.setAttribute('stroke-width', '2'); ring.setAttribute('stroke-opacity', '0.8'); const animR = document.createElementNS(ns, 'animate'); animR.setAttribute('attributeName', 'r'); animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`); animR.setAttribute('dur', '1.5s'); animR.setAttribute('repeatCount', 'indefinite'); ring.appendChild(animR); const animO = document.createElementNS(ns, 'animate'); animO.setAttribute('attributeName', 'stroke-opacity'); animO.setAttribute('values', '0.8;0.4;0.8'); animO.setAttribute('dur', '1.5s'); animO.setAttribute('repeatCount', 'indefinite'); ring.appendChild(animO); // Insert after the hit area const hitArea = el.querySelector('.radar-device-hitarea'); if (hitArea && hitArea.nextSibling) { el.insertBefore(ring, hitArea.nextSibling); } else { el.insertBefore(ring, el.firstChild); } } } /** * Clear device highlighting (in-place update, no full re-render) */ function clearHighlight() { const prev = selectedDeviceKey; selectedDeviceKey = null; if (!svg || !prev) { return; } const devicesGroup = svg.querySelector('.radar-devices'); if (!devicesGroup) { return; } const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`); if (oldEl) { oldEl.classList.remove('selected'); const ring = oldEl.querySelector('.radar-select-ring'); if (ring) ring.remove(); const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)'); if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') { const device = devices.get(prev); const confidence = device ? (device.distance_confidence || 0.5) : 0.5; dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5); dot.setAttribute('stroke', dot.getAttribute('fill')); dot.setAttribute('stroke-width', '1'); } } } /** * 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, highlightDevice, clearHighlight, isPaused: () => isPaused, getFilter: () => activeFilter, getSelectedDevice: () => selectedDeviceKey, }; })(); // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = ProximityRadar; } window.ProximityRadar = ProximityRadar;