Rewrite proximity radar to use in-place DOM updates

Instead of rebuilding devicesGroup.innerHTML on every render, mutate
existing SVG elements in-place (update transforms, attributes, class
names) and only create/remove elements when devices genuinely appear
or disappear from the visible set.

This eliminates the root cause of both the jitter and the blank-radar
regression: hover state can never be disrupted by a render because the
DOM elements under the cursor are never destroyed. The isHovered /
renderPending / interactionLockUntil state machine and its associated
mouseover/mouseout listeners are removed entirely — they are no longer
needed. A shared buildSelectRing() helper deduplicates the animated
selection ring construction used by renderDevices() and
applySelectionToElement(). Closes #143.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-21 14:29:41 +00:00
parent 00be3e940a
commit 00681840c8
+155 -69
View File
@@ -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 `
<g transform="translate(${x}, ${y})">
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
</g>
`;
}).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;
}
/**