mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user