Fix radar jitter by using band-only positioning

Replace continuous estimated_distance_m-based radius with proximity band
snapping (immediate/near/far/unknown → fixed radius ratios of 0.15/0.40/
0.70/0.90). The proximity_band is computed server-side from rssi_ema which
is already smoothed, so it changes infrequently — dots now only move when
a device genuinely crosses a band boundary rather than on every RSSI
fluctuation.

Also removes the client-side EMA and positionCache added in the previous
commit, and reverts CSS style.transform back to SVG transform attribute to
avoid coordinate-system mismatch when the SVG is displayed at a scaled size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-21 14:38:50 +00:00
parent f8a6d0ae70
commit aba4ccd040
+13 -36
View File
@@ -25,14 +25,10 @@ const ProximityRadar = (function() {
newDeviceThreshold: 30, // seconds newDeviceThreshold: 30, // seconds
}; };
// Configuration
const POSITION_EMA_ALPHA = 0.25; // lower = more smoothing (0.25 → ~4 updates to reach 68% of a step)
// State // State
let container = null; let container = null;
let svg = null; let svg = null;
let devices = new Map(); let devices = new Map();
let positionCache = new Map(); // device_key → { x, y } smoothed position
let isPaused = false; let isPaused = false;
let activeFilter = null; let activeFilter = null;
let onDeviceClick = null; let onDeviceClick = null;
@@ -217,24 +213,13 @@ const ProximityRadar = (function() {
// Remove elements for devices no longer in the visible set // Remove elements for devices no longer in the visible set
devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => {
const k = el.getAttribute('data-device-key'); if (!visibleKeys.has(el.getAttribute('data-device-key'))) {
if (!visibleKeys.has(k)) {
positionCache.delete(k);
el.remove(); el.remove();
} }
}); });
visibleDevices.forEach(device => { visibleDevices.forEach(device => {
// Raw target position from distance/band const { x, y } = calculateDevicePosition(device, center, maxRadius);
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 confidence = device.distance_confidence || 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
const color = getBandColor(device.proximity_band); const color = getBandColor(device.proximity_band);
@@ -249,7 +234,7 @@ const ProximityRadar = (function() {
if (existing) { if (existing) {
// ── In-place update: mutate attributes, never recreate ── // ── In-place update: mutate attributes, never recreate ──
existing.style.transform = `translate(${x}px, ${y}px)`; existing.setAttribute('transform', `translate(${x}, ${y})`);
const innerG = existing.querySelector('.radar-device'); const innerG = existing.querySelector('.radar-device');
if (innerG) { if (innerG) {
@@ -306,8 +291,7 @@ const ProximityRadar = (function() {
const wrapperG = document.createElementNS(ns, 'g'); const wrapperG = document.createElementNS(ns, 'g');
wrapperG.classList.add('radar-device-wrapper'); wrapperG.classList.add('radar-device-wrapper');
wrapperG.setAttribute('data-device-key', key); wrapperG.setAttribute('data-device-key', key);
wrapperG.style.transform = `translate(${x}px, ${y}px)`; wrapperG.setAttribute('transform', `translate(${x}, ${y})`);
wrapperG.style.transition = 'transform 0.6s ease-out';
const innerG = document.createElementNS(ns, 'g'); const innerG = document.createElementNS(ns, 'g');
innerG.classList.add('radar-device'); innerG.classList.add('radar-device');
@@ -390,22 +374,16 @@ const ProximityRadar = (function() {
* Calculate device position on radar * Calculate device position on radar
*/ */
function calculateDevicePosition(device, center, maxRadius) { function calculateDevicePosition(device, center, maxRadius) {
// Calculate radius based on proximity band/distance // 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; let radiusRatio;
const band = device.proximity_band || 'unknown'; switch (device.proximity_band || 'unknown') {
case 'immediate': radiusRatio = 0.15; break;
if (device.estimated_distance_m != null) { case 'near': radiusRatio = 0.40; break;
// Use actual distance (log scale) case 'far': radiusRatio = 0.70; break;
const maxDistance = 15; default: radiusRatio = 0.90; break;
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) // Calculate angle based on device key hash (stable positioning)
@@ -462,7 +440,6 @@ const ProximityRadar = (function() {
*/ */
function clear() { function clear() {
devices.clear(); devices.clear();
positionCache.clear();
selectedDeviceKey = null; selectedDeviceKey = null;
renderDevices(); renderDevices();
} }