diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index eaf7a94..4f7a444 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -33,10 +33,7 @@ const ProximityRadar = (function() { let activeFilter = null; let onDeviceClick = null; let selectedDeviceKey = null; - let isHovered = false; - let renderPending = false; let renderTimer = null; - let interactionLockUntil = 0; // timestamp: suppress renders briefly after click /** * Initialize the radar component @@ -128,35 +125,10 @@ const ProximityRadar = (function() { if (!deviceEl) return; const deviceKey = deviceEl.getAttribute('data-device-key'); if (onDeviceClick && deviceKey) { - // Lock out re-renders briefly so the DOM stays stable after click - interactionLockUntil = Date.now() + 500; onDeviceClick(deviceKey); } }); - // mouseover/mouseout bubble, so we get events from all descendants. - // Use devicesGroup.contains(relatedTarget) to detect true entry/exit - // rather than capture-phase mouseenter/mouseleave, which can leave - // isHovered stuck when innerHTML is rebuilt under the cursor. - devicesGroup.addEventListener('mouseover', (e) => { - if (e.target.closest('.radar-device')) { - isHovered = true; - } - }); - - devicesGroup.addEventListener('mouseout', (e) => { - if (!e.target.closest('.radar-device')) return; - // Only clear hover when the mouse leaves the group entirely — - // moving between sibling children keeps relatedTarget inside the group. - if (!devicesGroup.contains(e.relatedTarget)) { - isHovered = false; - if (renderPending) { - renderPending = false; - renderDevices(); - } - } - }); - // Add sweep animation animateSweep(); } @@ -198,17 +170,10 @@ const ProximityRadar = (function() { function updateDevices(deviceList) { if (isPaused) return; - // Update device map deviceList.forEach(device => { devices.set(device.device_key, device); }); - // Defer render while user is hovering or interacting to prevent DOM rebuild flicker - if (isHovered || Date.now() < interactionLockUntil) { - renderPending = true; - return; - } - // Debounce rapid updates (e.g. per-device SSE events) if (renderTimer) clearTimeout(renderTimer); renderTimer = setTimeout(() => { @@ -218,7 +183,9 @@ const ProximityRadar = (function() { } /** - * Render device dots on the radar + * 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'); @@ -226,6 +193,7 @@ const ProximityRadar = (function() { 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()); @@ -241,47 +209,165 @@ const ProximityRadar = (function() { 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); + const visibleKeys = new Set(visibleDevices.map(d => d.device_key)); - // Calculate dot size based on confidence + // Remove elements for devices no longer in the visible set + devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { + if (!visibleKeys.has(el.getAttribute('data-device-key'))) { + el.remove(); + } + }); + + visibleDevices.forEach(device => { + const { x, y } = calculateDevicePosition(device, center, maxRadius); 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' : ''; - const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey; - - // Hit area size (prevents hover flicker when scaling) + const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); const hitAreaSize = Math.max(dotSize * 2, 15); + const key = device.device_key; - return ` - - - - - ${isSelected ? ` - - - ` : ''} - - ${device.is_new && !isSelected ? `` : ''} - ${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm) - - - `; - }).join(''); + const existing = devicesGroup.querySelector( + `.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]` + ); - devicesGroup.innerHTML = dots; + if (existing) { + // ── In-place update: mutate attributes, never recreate ── + existing.setAttribute('transform', `translate(${x}, ${y})`); + + 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.setAttribute('transform', `translate(${x}, ${y})`); + + 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; } /**