mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
The remaining jitter after the in-place DOM rewrite was caused by RSSI fluctuations propagating directly into dot positions on every 200ms update cycle. Two fixes: 1. Client-side EMA (alpha=0.25) on x/y coordinates per device. Each render blends 25% toward the new raw position and retains 75% of the smoothed position, filtering high-frequency RSSI noise without hiding genuine distance changes. positionCache is keyed by device_key and cleared on device removal or radar reset. 2. CSS transition (transform 0.6s ease-out) on each wrapper element. Switching from SVG transform attribute to style.transform enables native CSS transitions, so any remaining position change (e.g. a band crossing) animates smoothly rather than snapping. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
653 lines
25 KiB
JavaScript
653 lines
25 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
|
|
<defs>
|
|
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
|
|
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
|
|
</radialGradient>
|
|
<filter id="glow">
|
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
|
<feMerge>
|
|
<feMergeNode in="coloredBlur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Background gradient -->
|
|
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
|
|
fill="url(#radarGradient)" />
|
|
|
|
<!-- Proximity rings -->
|
|
<g class="radar-rings">
|
|
${CONFIG.rings.map((ring, i) => {
|
|
const r = ring.radius * (center - CONFIG.padding);
|
|
return `
|
|
<circle cx="${center}" cy="${center}" r="${r}"
|
|
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
|
|
stroke-width="1" stroke-dasharray="4,4" />
|
|
<text x="${center}" y="${center - r + 12}"
|
|
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
|
|
font-size="9" font-family="monospace">${ring.label}</text>
|
|
`;
|
|
}).join('')}
|
|
</g>
|
|
|
|
<!-- Sweep line (animated) -->
|
|
<line class="radar-sweep" x1="${center}" y1="${center}"
|
|
x2="${center}" y2="${CONFIG.padding}"
|
|
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
|
|
|
|
<!-- Center point -->
|
|
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
|
|
fill="#00d4ff" filter="url(#glow)" />
|
|
|
|
<!-- Device dots container -->
|
|
<g class="radar-devices"></g>
|
|
|
|
<!-- Legend -->
|
|
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
|
|
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
|
|
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
|
|
transform="translate(0, 10)">(signal strength)</text>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
|
|
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, '<')
|
|
.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;
|