mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary and was the reason dots activated when merely nearby. Changed to dotSize + 4 (proportional, 4px padding around the visual circle). Overlap spread: compute all band positions first, then run an iterative push-apart pass (spreadOverlappingDots) that nudges any two dots whose arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a band are stable across renders (same hash angle, same band = same output before spreading) so dots don't shuffle on every update. Z-order: sort visible devices by rssi_current ascending before rendering so the strongest signal lands last in SVG order and receives clicks when dots stack. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
690 lines
27 KiB
JavaScript
690 lines
27 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
|
|
};
|
|
|
|
// State
|
|
let container = null;
|
|
let svg = null;
|
|
let devices = new Map();
|
|
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 => {
|
|
if (!visibleKeys.has(el.getAttribute('data-device-key'))) {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
// Sort weakest signal first so strongest renders on top (SVG z-order)
|
|
visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100));
|
|
|
|
// Compute all positions upfront so we can spread overlapping dots
|
|
const posMap = new Map();
|
|
visibleDevices.forEach(device => {
|
|
posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius));
|
|
});
|
|
|
|
// Spread dots that land too close together within the same band.
|
|
// minGapPx = diameter of largest possible hit area + 2px breathing room.
|
|
const maxHitArea = CONFIG.dotMaxSize + 4;
|
|
spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2);
|
|
|
|
visibleDevices.forEach(device => {
|
|
const { x, y } = posMap.get(device.device_key);
|
|
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 = dotSize + 4;
|
|
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.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;
|
|
}
|
|
|
|
/**
|
|
* Calculate device position on radar
|
|
*/
|
|
function calculateDevicePosition(device, center, maxRadius) {
|
|
// Position is band-only — the band is computed server-side from rssi_ema
|
|
// (already smoothed), so it changes infrequently and never jitters.
|
|
// Using raw estimated_distance_m caused constant micro-movement as RSSI
|
|
// fluctuated on every update cycle.
|
|
let radiusRatio;
|
|
switch (device.proximity_band || 'unknown') {
|
|
case 'immediate': radiusRatio = 0.15; break;
|
|
case 'near': radiusRatio = 0.40; break;
|
|
case 'far': radiusRatio = 0.70; break;
|
|
default: radiusRatio = 0.90; 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, angle, radius };
|
|
}
|
|
|
|
/**
|
|
* Spread dots within the same band that land too close together.
|
|
* Groups entries by radius, sorts by angle, then nudges neighbours
|
|
* apart until the arc gap between any two dots is at least minGapPx.
|
|
* Positions are updated in-place on the entry objects.
|
|
*/
|
|
function spreadOverlappingDots(entries, center, minGapPx) {
|
|
const groups = new Map();
|
|
entries.forEach(e => {
|
|
const key = Math.round(e.radius);
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(e);
|
|
});
|
|
|
|
groups.forEach((group, r) => {
|
|
if (group.length < 2 || r < 1) return;
|
|
const minSep = minGapPx / r; // radians
|
|
|
|
group.sort((a, b) => a.angle - b.angle);
|
|
|
|
// Iterative push-apart (up to 8 passes)
|
|
for (let iter = 0; iter < 8; iter++) {
|
|
let moved = false;
|
|
for (let i = 0; i < group.length; i++) {
|
|
const j = (i + 1) % group.length;
|
|
let gap = group[j].angle - group[i].angle;
|
|
if (gap < 0) gap += 2 * Math.PI;
|
|
if (gap < minSep) {
|
|
const push = (minSep - gap) / 2;
|
|
group[i].angle -= push;
|
|
group[j].angle += push;
|
|
moved = true;
|
|
}
|
|
}
|
|
if (!moved) break;
|
|
}
|
|
|
|
// Normalise angles back to [0, 2π) and recompute x/y
|
|
group.forEach(e => {
|
|
e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
e.x = center + Math.sin(e.angle) * r;
|
|
e.y = center - Math.cos(e.angle) * r;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
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;
|